v0.1.1: System default AI config, configurable URLs, single .env, website refactor
This commit is contained in:
44
.env.example
Normal file
44
.env.example
Normal file
@@ -0,0 +1,44 @@
|
||||
# =============================================================================
|
||||
# DearDiary Configuration
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Backend Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
BACKEND_DATABASE_URL="file:./data/deardiary.db"
|
||||
BACKEND_PORT="3000"
|
||||
BACKEND_JWT_SECRET="change-this-to-a-random-string-in-production"
|
||||
BACKEND_CORS_ORIGIN="*"
|
||||
BACKEND_REGISTRATION_ENABLED="false"
|
||||
BACKEND_MEDIA_DIR="./data/media"
|
||||
|
||||
# Default admin user (auto-created on startup if doesn't exist)
|
||||
BACKEND_DEFAULT_USER_EMAIL="admin@localhost"
|
||||
BACKEND_DEFAULT_USER_PASSWORD="changeme123"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Default AI Configuration for New Users
|
||||
# -----------------------------------------------------------------------------
|
||||
# When set, new users will use these settings by default.
|
||||
# Users can override these in their personal settings.
|
||||
|
||||
# Default AI provider: groq, openai, anthropic, ollama, lmstudio, xai, custom
|
||||
BACKEND_DEFAULT_AI_PROVIDER="groq"
|
||||
|
||||
# Default model (provider-specific)
|
||||
BACKEND_DEFAULT_AI_MODEL="llama-3.3-70b-versatile"
|
||||
|
||||
# Default API key (optional - leave empty to let users provide their own)
|
||||
BACKEND_DEFAULT_AI_API_KEY=""
|
||||
|
||||
# Default base URL (for local providers like ollama/lmstudio)
|
||||
BACKEND_DEFAULT_AI_BASE_URL=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Website Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# URL where the app is hosted (used in "Join Free Alpha" links)
|
||||
WEBSITE_APP_URL="http://localhost:3000"
|
||||
|
||||
# Git repository URL
|
||||
GIT_URL="https://git.kropa.tech/lotherk/deardiary"
|
||||
19
AGENTS.md
19
AGENTS.md
@@ -78,6 +78,12 @@ id, userId, date, type, content, mediaPath, metadata, latitude, longitude, place
|
||||
- Location is captured automatically from browser geolocation when creating events
|
||||
- Reverse geocoding via OpenStreetMap Nominatim API provides place names
|
||||
|
||||
### Settings Model
|
||||
```
|
||||
userId, aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language, timezone, providerSettings, journalContextDays, useSystemDefault
|
||||
```
|
||||
- `useSystemDefault`: When true, user uses system-default AI settings from environment variables
|
||||
|
||||
## API Design
|
||||
|
||||
All endpoints return: `{ data: T | null, error: { code, message } | null }`
|
||||
@@ -146,6 +152,18 @@ AI is configured to return JSON with `response_format: { type: "json_object" }`
|
||||
### Provider Settings Storage
|
||||
Settings stored as `providerSettings: { "groq": { apiKey, model, baseUrl }, ... }` with `aiProvider` determining which is active.
|
||||
|
||||
### System Default AI Configuration
|
||||
Administrators can configure default AI settings via environment variables that apply to all new users:
|
||||
```
|
||||
BACKEND_DEFAULT_AI_PROVIDER="groq"
|
||||
BACKEND_DEFAULT_AI_MODEL="llama-3.3-70b-versatile"
|
||||
BACKEND_DEFAULT_AI_API_KEY=""
|
||||
BACKEND_DEFAULT_AI_BASE_URL=""
|
||||
```
|
||||
- New users automatically use system defaults (`useSystemDefault: true`)
|
||||
- Users can override with their own settings by unchecking "Use system default settings"
|
||||
- When `useSystemDefault` is true, the system ignores user's individual AI settings and uses env defaults
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### TypeScript
|
||||
@@ -206,6 +224,7 @@ bun run test:server
|
||||
Tests require the server running. The test script starts the server, runs tests, then stops it.
|
||||
|
||||
## Version History
|
||||
- 0.1.1: System default AI configuration via env vars, "Use system default" checkbox in settings
|
||||
- 0.1.0: Automatic geolocation capture, Starlight documentation site
|
||||
- 0.0.6: Automatic geolocation capture on event creation, reverse geocoding to place names
|
||||
- 0.0.5: Export/Import feature with version checking
|
||||
|
||||
@@ -10,11 +10,13 @@ COPY backend/src ./src
|
||||
RUN bun build src/index.ts --outdir ./dist --target bun
|
||||
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
ARG VITE_GIT_URL=https://git.kropa.tech/lotherk/deardiary
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install
|
||||
COPY frontend ./
|
||||
ENV VITE_GIT_URL=$VITE_GIT_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM oven/bun:1.1-alpine AS runner
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY www/ /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
11
Dockerfile.website
Normal file
11
Dockerfile.website
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY www/ /usr/share/nginx/html/
|
||||
COPY docker-entrypoint.d/ /docker-entrypoint.d/
|
||||
|
||||
RUN apk add --no-cache gettext && \
|
||||
chmod +x /docker-entrypoint.d/*.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.d/30-envsubst.sh", "--", "nginx", "-g", "daemon off;"]
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,51 +0,0 @@
|
||||
# =============================================================================
|
||||
# DearDiary Configuration
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database (SQLite, PostgreSQL, or MySQL)
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL="file:./data/deardiary.db"
|
||||
|
||||
# Example PostgreSQL:
|
||||
# DATABASE_URL="postgresql://postgres:password@db:5432/deardiary"
|
||||
|
||||
# Example MySQL:
|
||||
# DATABASE_URL="mysql://root:password@localhost:3306/deardiary"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Application
|
||||
# -----------------------------------------------------------------------------
|
||||
# App name displayed in UI
|
||||
APP_NAME="DearDiary"
|
||||
|
||||
# App version
|
||||
VERSION="0.1.0"
|
||||
|
||||
# Server port
|
||||
PORT="3000"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Security
|
||||
# -----------------------------------------------------------------------------
|
||||
# JWT secret for authentication tokens (REQUIRED in production)
|
||||
JWT_SECRET="change-this-to-a-random-string-in-production"
|
||||
|
||||
# CORS origin (use specific domain in production, e.g., "https://yourapp.com")
|
||||
CORS_ORIGIN="*"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# User Management
|
||||
# -----------------------------------------------------------------------------
|
||||
# Enable/disable user registration ("true" or "false")
|
||||
REGISTRATION_ENABLED="false"
|
||||
|
||||
# Default admin user (auto-created on startup if doesn't exist)
|
||||
DEFAULT_USER_EMAIL="admin@localhost"
|
||||
DEFAULT_USER_PASSWORD="changeme123"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Storage
|
||||
# -----------------------------------------------------------------------------
|
||||
# Media storage directory
|
||||
MEDIA_DIR="./data/media"
|
||||
@@ -4,7 +4,7 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
url = env("BACKEND_DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -104,6 +104,7 @@ model Settings {
|
||||
timezone String @default("UTC")
|
||||
providerSettings String?
|
||||
journalContextDays Int @default(10)
|
||||
useSystemDefault Boolean @default(true)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
@@ -8,19 +8,35 @@ import { createHash, randomBytes } from 'crypto';
|
||||
import * as jose from 'jose';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createAIProvider } from './services/ai/provider';
|
||||
import { eventsRoutes } from './routes/events';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const envVars = env(app);
|
||||
const envVars = env<{
|
||||
BACKEND_DATABASE_URL?: string;
|
||||
BACKEND_PORT?: string;
|
||||
BACKEND_JWT_SECRET?: string;
|
||||
BACKEND_CORS_ORIGIN?: string;
|
||||
BACKEND_REGISTRATION_ENABLED?: string;
|
||||
BACKEND_MEDIA_DIR?: string;
|
||||
BACKEND_DEFAULT_USER_EMAIL?: string;
|
||||
BACKEND_DEFAULT_USER_PASSWORD?: string;
|
||||
BACKEND_DEFAULT_AI_PROVIDER?: string;
|
||||
BACKEND_DEFAULT_AI_MODEL?: string;
|
||||
BACKEND_DEFAULT_AI_API_KEY?: string;
|
||||
BACKEND_DEFAULT_AI_BASE_URL?: string;
|
||||
BACKEND_VERSION?: string;
|
||||
BACKEND_APP_NAME?: string;
|
||||
}>(app);
|
||||
|
||||
app.use('*', logger());
|
||||
app.use('*', cors({
|
||||
origin: envVars.CORS_ORIGIN || '*',
|
||||
origin: envVars.BACKEND_CORS_ORIGIN || '*',
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
datasourceUrl: envVars.DATABASE_URL || 'file:./data/deardiary.db',
|
||||
datasourceUrl: envVars.BACKEND_DATABASE_URL || 'file:./data/deardiary.db',
|
||||
});
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
@@ -104,7 +120,7 @@ app.post('/api/v1/auth/login', async (c) => {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401);
|
||||
}
|
||||
|
||||
const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production';
|
||||
const jwtSecret = envVars.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
|
||||
const token = await new jose.SignJWT({ userId: user.id })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
@@ -117,9 +133,9 @@ app.post('/api/v1/auth/login', async (c) => {
|
||||
app.get('/api/v1/server-info', async (c) => {
|
||||
return c.json({
|
||||
data: {
|
||||
version: envVars.VERSION || '0.1.0',
|
||||
registrationEnabled: envVars.REGISTRATION_ENABLED !== 'false',
|
||||
appName: envVars.APP_NAME || 'DearDiary',
|
||||
version: envVars.BACKEND_VERSION || '0.1.0',
|
||||
registrationEnabled: envVars.BACKEND_REGISTRATION_ENABLED !== 'false',
|
||||
appName: envVars.BACKEND_APP_NAME || 'DearDiary',
|
||||
},
|
||||
error: null
|
||||
});
|
||||
@@ -132,7 +148,7 @@ app.post('/api/v1/auth/api-key', async (c) => {
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production';
|
||||
const jwtSecret = envVars.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
@@ -293,22 +309,7 @@ app.get('/api/v1/search', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/v1/events/:id', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const { id } = c.req.param();
|
||||
const existing = await prisma.event.findFirst({ where: { id, userId } });
|
||||
if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
|
||||
|
||||
const journal = await prisma.journal.findFirst({ where: { userId, date: existing.date } });
|
||||
if (journal) {
|
||||
return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot delete event: journal already generated. Delete the journal first.' } }, 400);
|
||||
}
|
||||
|
||||
await prisma.event.delete({ where: { id } });
|
||||
return c.json({ data: { deleted: true }, error: null });
|
||||
});
|
||||
app.route('/api/v1', eventsRoutes);
|
||||
|
||||
// Journal routes
|
||||
app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
@@ -333,7 +334,7 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
|
||||
const providerConfig = providerSettings[provider] || {};
|
||||
const apiKey = providerConfig.apiKey || settings?.aiApiKey;
|
||||
|
||||
if ((provider === 'openai' || provider === 'anthropic' || provider === 'groq') && !apiKey) {
|
||||
if ((provider === 'openai' || provider === 'anthropic' || provider === 'groq' || provider === 'xai') && !apiKey) {
|
||||
return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400);
|
||||
}
|
||||
|
||||
@@ -602,15 +603,28 @@ app.get('/api/v1/journals', async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
const getDefaultSettings = () => ({
|
||||
aiProvider: envVars.BACKEND_DEFAULT_AI_PROVIDER || 'groq',
|
||||
aiApiKey: envVars.BACKEND_DEFAULT_AI_API_KEY || null,
|
||||
aiModel: envVars.BACKEND_DEFAULT_AI_MODEL || 'llama-3.3-70b-versatile',
|
||||
aiBaseUrl: envVars.BACKEND_DEFAULT_AI_BASE_URL || null,
|
||||
useSystemDefault: true,
|
||||
});
|
||||
|
||||
const getUserSettings = async (userId: string) => {
|
||||
let settings = await prisma.settings.findUnique({ where: { userId } });
|
||||
if (!settings) {
|
||||
settings = await prisma.settings.create({ data: { userId, ...getDefaultSettings() } });
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
|
||||
// Settings routes
|
||||
app.get('/api/v1/settings', async (c) => {
|
||||
const userId = await getUserId(c);
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
let settings = await prisma.settings.findUnique({ where: { userId } });
|
||||
if (!settings) {
|
||||
settings = await prisma.settings.create({ data: { userId } });
|
||||
}
|
||||
let settings = await getUserSettings(userId);
|
||||
|
||||
const oldDefaultLength = 400;
|
||||
if (settings.journalPrompt && settings.journalPrompt.length > oldDefaultLength) {
|
||||
@@ -625,21 +639,33 @@ app.put('/api/v1/settings', async (c) => {
|
||||
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language, providerSettings, journalContextDays } = body;
|
||||
const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language, providerSettings, journalContextDays, useSystemDefault } = body;
|
||||
|
||||
const defaults = getDefaultSettings();
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (aiProvider !== undefined) data.aiProvider = aiProvider;
|
||||
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
|
||||
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey || null;
|
||||
if (aiModel !== undefined) data.aiModel = aiModel;
|
||||
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
|
||||
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl || null;
|
||||
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt === '' ? null : journalPrompt;
|
||||
if (language !== undefined) data.language = language;
|
||||
if (providerSettings !== undefined) data.providerSettings = JSON.stringify(providerSettings);
|
||||
if (journalContextDays !== undefined) data.journalContextDays = journalContextDays;
|
||||
if (useSystemDefault !== undefined) data.useSystemDefault = useSystemDefault;
|
||||
|
||||
const shouldUseSystemDefaults = useSystemDefault === true;
|
||||
|
||||
if (shouldUseSystemDefaults) {
|
||||
if (aiProvider === undefined) data.aiProvider = defaults.aiProvider;
|
||||
if (aiApiKey === undefined) data.aiApiKey = defaults.aiApiKey;
|
||||
if (aiModel === undefined) data.aiModel = defaults.aiModel;
|
||||
if (aiBaseUrl === undefined) data.aiBaseUrl = defaults.aiBaseUrl;
|
||||
}
|
||||
|
||||
const settings = await prisma.settings.upsert({
|
||||
where: { userId },
|
||||
create: { userId, ...data },
|
||||
create: { userId, ...getDefaultSettings(), ...data },
|
||||
update: data,
|
||||
});
|
||||
|
||||
@@ -1030,10 +1056,7 @@ app.post('/api/v1/account/reset', async (c) => {
|
||||
await prisma.settings.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
aiProvider: 'groq',
|
||||
aiApiKey: null,
|
||||
aiModel: 'llama-3.3-70b-versatile',
|
||||
aiBaseUrl: null,
|
||||
...getDefaultSettings(),
|
||||
journalPrompt: null,
|
||||
language: 'en',
|
||||
timezone: 'UTC',
|
||||
@@ -1089,7 +1112,7 @@ app.post('/api/v1/account/password', async (c) => {
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/register', async (c) => {
|
||||
const registrationEnabled = envVars.REGISTRATION_ENABLED !== 'false';
|
||||
const registrationEnabled = envVars.BACKEND_REGISTRATION_ENABLED !== 'false';
|
||||
if (!registrationEnabled) {
|
||||
return c.json({ data: null, error: { code: 'REGISTRATION_DISABLED', message: 'Registration is currently disabled' } }, 403);
|
||||
}
|
||||
@@ -1184,11 +1207,11 @@ async function rebuildFTS() {
|
||||
}
|
||||
|
||||
async function createDefaultUser() {
|
||||
const defaultEmail = envVars.DEFAULT_USER_EMAIL;
|
||||
const defaultPassword = envVars.DEFAULT_USER_PASSWORD;
|
||||
const defaultEmail = envVars.BACKEND_DEFAULT_USER_EMAIL;
|
||||
const defaultPassword = envVars.BACKEND_DEFAULT_USER_PASSWORD;
|
||||
|
||||
if (!defaultEmail || !defaultPassword) {
|
||||
console.log('No default user configured (set DEFAULT_USER_EMAIL and DEFAULT_USER_PASSWORD)');
|
||||
console.log('No default user configured (set BACKEND_DEFAULT_USER_EMAIL and BACKEND_DEFAULT_USER_PASSWORD)');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1232,7 +1255,7 @@ async function createDefaultUser() {
|
||||
}
|
||||
}
|
||||
|
||||
const port = parseInt(envVars.PORT || '3000', 10);
|
||||
const port = parseInt(envVars.BACKEND_PORT || '3000', 10);
|
||||
console.log(`Starting DearDiary API on port ${port}`);
|
||||
|
||||
setupFTS().then(() => {
|
||||
|
||||
@@ -6,10 +6,19 @@ export interface HonoEnv {
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
Bindings: {
|
||||
DATABASE_URL: string;
|
||||
JWT_SECRET: string;
|
||||
MEDIA_DIR: string;
|
||||
PORT: string;
|
||||
CORS_ORIGIN: string;
|
||||
BACKEND_DATABASE_URL: string;
|
||||
BACKEND_JWT_SECRET: string;
|
||||
BACKEND_MEDIA_DIR: string;
|
||||
BACKEND_PORT: string;
|
||||
BACKEND_CORS_ORIGIN: string;
|
||||
BACKEND_REGISTRATION_ENABLED: string;
|
||||
BACKEND_DEFAULT_USER_EMAIL: string;
|
||||
BACKEND_DEFAULT_USER_PASSWORD: string;
|
||||
BACKEND_DEFAULT_AI_PROVIDER: string;
|
||||
BACKEND_DEFAULT_AI_MODEL: string;
|
||||
BACKEND_DEFAULT_AI_API_KEY: string;
|
||||
BACKEND_DEFAULT_AI_BASE_URL: string;
|
||||
BACKEND_VERSION: string;
|
||||
BACKEND_APP_NAME: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +34,32 @@ export async function authMiddleware(c: Context<HonoEnv>, next: Next) {
|
||||
|
||||
export function authenticate() {
|
||||
return async (c: Context<HonoEnv>, next: Next) => {
|
||||
await authMiddleware(c, next);
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid Authorization header' } }, 401);
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
const keyHash = createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const prisma = c.get('prisma');
|
||||
|
||||
const keyRecord = await prisma.apiKey.findUnique({
|
||||
where: { keyHash },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!keyRecord) {
|
||||
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
|
||||
}
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { id: keyRecord.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
|
||||
c.set('userId', keyRecord.userId);
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ authRoutes.post('/login', async (c) => {
|
||||
}
|
||||
|
||||
const prisma = c.get('prisma');
|
||||
const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production';
|
||||
const jwtSecret = c.env.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
@@ -88,7 +88,7 @@ authRoutes.post('/api-key', async (c) => {
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production';
|
||||
const jwtSecret = c.env.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HonoEnv } from '../lib/types';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
|
||||
export const eventsRoutes = new Hono<HonoEnv>();
|
||||
|
||||
eventsRoutes.post('/', async (c) => {
|
||||
eventsRoutes.use(authenticate());
|
||||
|
||||
eventsRoutes.post('/events', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const prisma = c.get('prisma');
|
||||
const mediaDir = c.env.MEDIA_DIR || './data/media';
|
||||
const mediaDir = c.env.BACKEND_MEDIA_DIR || './data/media';
|
||||
|
||||
const body = await c.req.json();
|
||||
const { date, type, content, metadata, latitude, longitude, placeName } = body;
|
||||
@@ -42,7 +45,7 @@ eventsRoutes.post('/', async (c) => {
|
||||
return c.json({ data: event, error: null }, 201);
|
||||
});
|
||||
|
||||
eventsRoutes.get('/:id', async (c) => {
|
||||
eventsRoutes.get('/events/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { id } = c.req.param();
|
||||
const prisma = c.get('prisma');
|
||||
@@ -58,7 +61,7 @@ eventsRoutes.get('/:id', async (c) => {
|
||||
return c.json({ data: event, error: null });
|
||||
});
|
||||
|
||||
eventsRoutes.put('/:id', async (c) => {
|
||||
eventsRoutes.put('/events/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { id } = c.req.param();
|
||||
const prisma = c.get('prisma');
|
||||
@@ -97,7 +100,7 @@ eventsRoutes.put('/:id', async (c) => {
|
||||
return c.json({ data: event, error: null });
|
||||
});
|
||||
|
||||
eventsRoutes.delete('/:id', async (c) => {
|
||||
eventsRoutes.delete('/events/:id', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { id } = c.req.param();
|
||||
const prisma = c.get('prisma');
|
||||
@@ -120,11 +123,11 @@ eventsRoutes.delete('/:id', async (c) => {
|
||||
return c.json({ data: { deleted: true }, error: null });
|
||||
});
|
||||
|
||||
eventsRoutes.post('/:id/photo', async (c) => {
|
||||
eventsRoutes.post('/events/:id/photo', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { id } = c.req.param();
|
||||
const prisma = c.get('prisma');
|
||||
const mediaDir = c.env.MEDIA_DIR || './data/media';
|
||||
const mediaDir = c.env.BACKEND_MEDIA_DIR || './data/media';
|
||||
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { id, userId },
|
||||
@@ -157,11 +160,11 @@ eventsRoutes.post('/:id/photo', async (c) => {
|
||||
return c.json({ data: { mediaPath: mediaUrl }, error: null }, 201);
|
||||
});
|
||||
|
||||
eventsRoutes.post('/:id/voice', async (c) => {
|
||||
eventsRoutes.post('/events/:id/voice', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { id } = c.req.param();
|
||||
const prisma = c.get('prisma');
|
||||
const mediaDir = c.env.MEDIA_DIR || './data/media';
|
||||
const mediaDir = c.env.BACKEND_MEDIA_DIR || './data/media';
|
||||
|
||||
const event = await prisma.event.findFirst({
|
||||
where: { id, userId },
|
||||
|
||||
@@ -40,6 +40,7 @@ import { AnthropicProvider } from './anthropic';
|
||||
import { OllamaProvider } from './ollama';
|
||||
import { LMStudioProvider } from './lmstudio';
|
||||
import { GroqProvider } from './groq';
|
||||
import { XAIProvider } from './xai';
|
||||
import { CustomProvider } from './custom';
|
||||
|
||||
export function createAIProvider(config: AIProviderConfig): AIProvider {
|
||||
@@ -56,6 +57,8 @@ export function createAIProvider(config: AIProviderConfig): AIProvider {
|
||||
return new LMStudioProvider(config, settings);
|
||||
case 'groq':
|
||||
return new GroqProvider(config, settings);
|
||||
case 'xai':
|
||||
return new XAIProvider(config, settings);
|
||||
case 'custom':
|
||||
return new CustomProvider(config, settings);
|
||||
default:
|
||||
|
||||
92
backend/src/services/ai/xai.ts
Normal file
92
backend/src/services/ai/xai.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { AIProvider, AIProviderConfig, AIProviderResult } from './provider';
|
||||
import { ProviderSettings } from './openai';
|
||||
|
||||
export class XAIProvider implements AIProvider {
|
||||
provider = 'xai' as const;
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
private baseUrl: string;
|
||||
private customHeaders?: Record<string, string>;
|
||||
private customParameters?: Record<string, unknown>;
|
||||
|
||||
constructor(config: AIProviderConfig, settings?: ProviderSettings) {
|
||||
this.apiKey = config.apiKey || '';
|
||||
this.model = config.model || 'grok-4-1-fast-reasoning';
|
||||
this.baseUrl = config.baseUrl || 'https://api.x.ai/v1';
|
||||
this.customHeaders = settings?.headers;
|
||||
this.customParameters = settings?.parameters;
|
||||
}
|
||||
|
||||
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
|
||||
if (systemPrompt) {
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
|
||||
messages.push({ role: 'user', content: prompt });
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.model,
|
||||
messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
...this.customParameters,
|
||||
};
|
||||
|
||||
if (options?.jsonMode) {
|
||||
requestBody.response_format = { type: 'json_object' };
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
...this.customHeaders,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`xAI API error: ${response.status} ${JSON.stringify(responseData)}`);
|
||||
}
|
||||
|
||||
let content = responseData.choices?.[0]?.message?.content || '';
|
||||
let title: string | undefined;
|
||||
|
||||
if (options?.jsonMode) {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
title = parsed.title;
|
||||
content = parsed.content || content;
|
||||
} catch {
|
||||
// If JSON parsing fails, use content as-is
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
title,
|
||||
request: requestBody,
|
||||
response: responseData,
|
||||
};
|
||||
}
|
||||
|
||||
async validate(): Promise<boolean> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
...this.customHeaders,
|
||||
};
|
||||
const response = await fetch(`${this.baseUrl}/models`, { headers });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,46 @@
|
||||
services:
|
||||
website:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.website
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
- APP_URL=${WEBSITE_APP_URL:-http://localhost:3000}
|
||||
- GIT_URL=${GIT_URL:-https://github.com/lotherk/deardiary}
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_GIT_URL=${GIT_URL:-https://git.kropa.tech/lotherk/deardiary}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "5173:80"
|
||||
- "3000:80"
|
||||
environment:
|
||||
- DATABASE_URL=file:/data/deardiary.db
|
||||
- MEDIA_DIR=/data/media
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- PORT=3000
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-*}
|
||||
- DEFAULT_USER_EMAIL=${DEFAULT_USER_EMAIL:-}
|
||||
- DEFAULT_USER_PASSWORD=${DEFAULT_USER_PASSWORD:-}
|
||||
- BACKEND_DATABASE_URL=file:/data/deardiary.db
|
||||
- BACKEND_MEDIA_DIR=/data/media
|
||||
- BACKEND_JWT_SECRET=${BACKEND_JWT_SECRET:-change-me-in-production}
|
||||
- BACKEND_PORT=3001
|
||||
- BACKEND_CORS_ORIGIN=${BACKEND_CORS_ORIGIN:-*}
|
||||
- BACKEND_REGISTRATION_ENABLED=${BACKEND_REGISTRATION_ENABLED:-false}
|
||||
- BACKEND_DEFAULT_USER_EMAIL=${BACKEND_DEFAULT_USER_EMAIL:-}
|
||||
- BACKEND_DEFAULT_USER_PASSWORD=${BACKEND_DEFAULT_USER_PASSWORD:-}
|
||||
- BACKEND_DEFAULT_AI_PROVIDER=${BACKEND_DEFAULT_AI_PROVIDER:-groq}
|
||||
- BACKEND_DEFAULT_AI_MODEL=${BACKEND_DEFAULT_AI_MODEL:-llama-3.3-70b-versatile}
|
||||
- BACKEND_DEFAULT_AI_API_KEY=${BACKEND_DEFAULT_AI_API_KEY:-}
|
||||
- BACKEND_DEFAULT_AI_BASE_URL=${BACKEND_DEFAULT_AI_BASE_URL:-}
|
||||
volumes:
|
||||
- deardiary_data:/data
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
docs:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.docs
|
||||
ports:
|
||||
- "4000:80"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
deardiary_data:
|
||||
|
||||
10
docker-entrypoint.d/30-envsubst.sh
Executable file
10
docker-entrypoint.d/30-envsubst.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Run envsubst on index.html
|
||||
if [ -f /usr/share/nginx/html/index.html ]; then
|
||||
envsubst < /usr/share/nginx/html/index.html > /usr/share/nginx/html/index.html.tmp
|
||||
mv /usr/share/nginx/html/index.html.tmp /usr/share/nginx/html/index.html
|
||||
fi
|
||||
|
||||
exec nginx -g 'daemon off;'
|
||||
@@ -81,7 +81,7 @@ function Footer({ appName = 'DearDiary' }: { appName?: string }) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
return (
|
||||
<footer className={`py-6 text-center text-sm text-slate-500 ${resolvedTheme === 'dark' ? 'border-t border-slate-800' : 'border-t border-slate-200'}`}>
|
||||
<p>{appName} v{packageJson.version} — Self-hosted AI-powered journaling · <a href="https://github.com/lotherk/deardiary" className="hover:text-purple-400 transition">GitHub</a> · <a href="https://deardiary.io" className="hover:text-purple-400 transition">deardiary.io</a> · MIT License · © 2026 Konrad Lother</p>
|
||||
<p>{appName} v{packageJson.version} — Self-hosted AI-powered journaling · <a href={import.meta.env.VITE_GIT_URL || 'https://git.kropa.tech/lotherk/deardiary'} className="hover:text-purple-400 transition">Git Repository</a> · MIT License · © 2026 Konrad Lother</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,30 @@ export default function QuickAddWidget({ isOpen, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
checkDiaryStatus();
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
if (content.trim() && !locked && !loading) {
|
||||
const form = inputRef.current?.form;
|
||||
if (form) {
|
||||
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
|
||||
form.dispatchEvent(submitEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, content, locked, loading]);
|
||||
|
||||
const checkDiaryStatus = async () => {
|
||||
const res = await api.getDay(today);
|
||||
if (res.data) {
|
||||
|
||||
@@ -221,7 +221,7 @@ export interface Task {
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq';
|
||||
aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq' | 'xai' | 'custom';
|
||||
aiApiKey?: string;
|
||||
aiModel: string;
|
||||
aiBaseUrl?: string;
|
||||
@@ -229,10 +229,13 @@ export interface Settings {
|
||||
language: string;
|
||||
timezone: string;
|
||||
journalContextDays: number;
|
||||
useSystemDefault: boolean;
|
||||
providerSettings?: Record<string, {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
parameters?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ interface ProviderSettings {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FullSettings extends Settings {
|
||||
@@ -15,15 +17,21 @@ interface FullSettings extends Settings {
|
||||
|
||||
const DEFAULT_MODELS: Record<string, string> = {
|
||||
groq: 'llama-3.3-70b-versatile',
|
||||
openai: 'gpt-4o',
|
||||
openai: 'gpt-5-nano',
|
||||
anthropic: 'claude-3-5-sonnet-20241022',
|
||||
ollama: 'llama3.2',
|
||||
lmstudio: 'local-model',
|
||||
custom: '',
|
||||
xai: 'grok-4-1-fast-reasoning',
|
||||
};
|
||||
|
||||
const PROVIDERS_WITH_API_KEY: string[] = ['openai', 'anthropic', 'groq', 'xai', 'custom'];
|
||||
const PROVIDERS_WITH_BASE_URL: string[] = ['ollama', 'lmstudio', 'custom'];
|
||||
|
||||
const DEFAULT_BASE_URLS: Record<string, string> = {
|
||||
ollama: 'http://localhost:11434',
|
||||
lmstudio: 'http://localhost:1234/v1',
|
||||
custom: 'https://your-api.example.com/v1',
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -34,6 +42,7 @@ export default function SettingsPage() {
|
||||
language: 'en',
|
||||
timezone: 'UTC',
|
||||
journalContextDays: 10,
|
||||
useSystemDefault: true,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -80,6 +89,9 @@ export default function SettingsPage() {
|
||||
if (!data.providerSettings) {
|
||||
data.providerSettings = {};
|
||||
}
|
||||
if (data.useSystemDefault === undefined) {
|
||||
data.useSystemDefault = true;
|
||||
}
|
||||
const provider = data.aiProvider || 'groq';
|
||||
const providerData = data.providerSettings[provider] || {};
|
||||
data.aiApiKey = providerData.apiKey;
|
||||
@@ -158,6 +170,7 @@ export default function SettingsPage() {
|
||||
await api.updateSettings({
|
||||
aiProvider: settings.aiProvider,
|
||||
providerSettings: newProviderSettings,
|
||||
useSystemDefault: settings.useSystemDefault,
|
||||
});
|
||||
setSaving(false);
|
||||
setSaved(true);
|
||||
@@ -448,13 +461,33 @@ export default function SettingsPage() {
|
||||
>
|
||||
<option value="groq">Groq (Free, Fast)</option>
|
||||
<option value="openai">OpenAI (GPT-4)</option>
|
||||
<option value="xai">xAI (Grok)</option>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="ollama">Ollama (Local)</option>
|
||||
<option value="lmstudio">LM Studio (Local)</option>
|
||||
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(provider === 'openai' || provider === 'anthropic' || provider === 'groq') && (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useSystemDefault"
|
||||
checked={settings.useSystemDefault}
|
||||
onChange={(e) => setSettings({ ...settings, useSystemDefault: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-600 bg-slate-800 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<label htmlFor="useSystemDefault" className="text-sm text-slate-300">
|
||||
Use system default settings
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 -mt-2">
|
||||
{settings.useSystemDefault
|
||||
? 'Using the default AI configuration set by the administrator.'
|
||||
: 'Override with your own AI settings.'}
|
||||
</p>
|
||||
|
||||
{!settings.useSystemDefault && PROVIDERS_WITH_API_KEY.includes(provider) && (
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">API Key</label>
|
||||
<input
|
||||
@@ -467,7 +500,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(provider === 'ollama' || provider === 'lmstudio') && (
|
||||
{!settings.useSystemDefault && PROVIDERS_WITH_BASE_URL.includes(provider) && (
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Base URL</label>
|
||||
<input
|
||||
@@ -480,6 +513,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!settings.useSystemDefault && (
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Model</label>
|
||||
<input
|
||||
@@ -490,6 +524,47 @@ export default function SettingsPage() {
|
||||
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!settings.useSystemDefault && (
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-sm text-slate-400 hover:text-slate-300 list-none flex items-center gap-2">
|
||||
<span className="transform transition-transform group-open:rotate-90">▶</span>
|
||||
Advanced Settings
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4 pl-4 border-l-2 border-slate-700">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Custom Headers (JSON)</label>
|
||||
<textarea
|
||||
value={JSON.stringify(currentProviderSettings.headers || {}, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
updateProviderSettings({ headers: JSON.parse(e.target.value || '{}') });
|
||||
} catch {}
|
||||
}}
|
||||
rows={3}
|
||||
placeholder={'{"Authorization": "Bearer ..."}'}
|
||||
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none resize-none font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Custom Parameters (JSON)</label>
|
||||
<textarea
|
||||
value={JSON.stringify(currentProviderSettings.parameters || {}, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
updateProviderSettings({ parameters: JSON.parse(e.target.value || '{}') });
|
||||
} catch {}
|
||||
}}
|
||||
rows={4}
|
||||
placeholder={'{"think": false}'}
|
||||
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none resize-none font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Examples: {"{ \"think\": false }"} for Ollama, {"{ \"thinking\": { \"type\": \"disabled\" } }"} for OpenAI</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 items-center">
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
@@ -12,4 +15,8 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.VITE_GIT_URL': JSON.stringify(env.VITE_GIT_URL || 'https://git.kropa.tech/lotherk/deardiary'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ server {
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
<div class="nav-links">
|
||||
<a href="#features">Features</a>
|
||||
<a href="docs/">Docs</a>
|
||||
<a href="https://github.com/lotherk/deardiary" target="_blank">GitHub</a>
|
||||
<a href="#try" class="btn btn-primary">Get Started</a>
|
||||
<a href="${GIT_URL}" target="_blank">Git Repository</a>
|
||||
<a href="${APP_URL}" class="btn btn-primary">Join Free Alpha</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -29,11 +29,11 @@
|
||||
<h1>Your AI-Powered<br><span class="gradient-text">Daily Journal</span></h1>
|
||||
<p class="hero-subtitle">Capture events throughout the day and let AI transform them into beautiful, thoughtful diary pages. Self-hosted. Private. Yours.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="#try" class="btn btn-primary btn-large">Start Free</a>
|
||||
<a href="${APP_URL}" class="btn btn-primary btn-large">Join Free Alpha</a>
|
||||
<a href="docs/" class="btn btn-secondary btn-large">Read Docs</a>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span>v0.1.0</span>
|
||||
<span>v0.1.1</span>
|
||||
<span>•</span>
|
||||
<span>Docker</span>
|
||||
<span>•</span>
|
||||
@@ -113,11 +113,16 @@
|
||||
<div class="container">
|
||||
<div class="try-content">
|
||||
<h2>Ready to start journaling?</h2>
|
||||
<p>Self-host in minutes with Docker. Your data stays on your server.</p>
|
||||
<p>Try our free alpha hosted for you, or self-host in minutes with Docker.</p>
|
||||
<div class="try-actions">
|
||||
<a href="${APP_URL}" class="btn btn-primary btn-large">Join Free Alpha</a>
|
||||
<span class="try-or">or</span>
|
||||
<a href="docs/getting-started/installation.html" class="btn btn-secondary btn-large">Self-Host</a>
|
||||
</div>
|
||||
<div class="try-code">
|
||||
<code>git clone https://github.com/lotherk/deardiary.git && cd deardiary && docker compose up -d</code>
|
||||
</div>
|
||||
<p class="try-hint">Default login: admin@localhost / changeme123</p>
|
||||
<p class="try-hint">Self-hosted default login: admin@localhost / changeme123</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -130,6 +135,7 @@
|
||||
<a href="#features">Features</a>
|
||||
<a href="docs/">Documentation</a>
|
||||
<a href="#how">How it Works</a>
|
||||
<a href="${APP_URL}">Free Alpha</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Resources</h4>
|
||||
@@ -139,9 +145,9 @@
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Project</h4>
|
||||
<a href="https://github.com/lotherk/deardiary" target="_blank">GitHub</a>
|
||||
<a href="https://github.com/lotherk/deardiary/issues" target="_blank">Issues</a>
|
||||
<a href="https://github.com/lotherk/deardiary/releases" target="_blank">Releases</a>
|
||||
<a href="${GIT_URL}" target="_blank">Source Code</a>
|
||||
<a href="${GIT_URL}/issues" target="_blank">Issues</a>
|
||||
<a href="${GIT_URL}/releases" target="_blank">Releases</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
|
||||
Reference in New Issue
Block a user