v0.1.1: System default AI config, configurable URLs, single .env, website refactor

This commit is contained in:
lotherk
2026-03-27 13:51:15 +00:00
parent 749a913309
commit f6c4da1da3
23 changed files with 486 additions and 180 deletions

44
.env.example Normal file
View 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"

View File

@@ -78,6 +78,12 @@ id, userId, date, type, content, mediaPath, metadata, latitude, longitude, place
- Location is captured automatically from browser geolocation when creating events - Location is captured automatically from browser geolocation when creating events
- Reverse geocoding via OpenStreetMap Nominatim API provides place names - 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 ## API Design
All endpoints return: `{ data: T | null, error: { code, message } | null }` 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 ### Provider Settings Storage
Settings stored as `providerSettings: { "groq": { apiKey, model, baseUrl }, ... }` with `aiProvider` determining which is active. 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 ## Coding Guidelines
### TypeScript ### 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. Tests require the server running. The test script starts the server, runs tests, then stops it.
## Version History ## 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.1.0: Automatic geolocation capture, Starlight documentation site
- 0.0.6: Automatic geolocation capture on event creation, reverse geocoding to place names - 0.0.6: Automatic geolocation capture on event creation, reverse geocoding to place names
- 0.0.5: Export/Import feature with version checking - 0.0.5: Export/Import feature with version checking

View File

@@ -10,11 +10,13 @@ COPY backend/src ./src
RUN bun build src/index.ts --outdir ./dist --target bun RUN bun build src/index.ts --outdir ./dist --target bun
FROM node:20-alpine AS frontend-builder FROM node:20-alpine AS frontend-builder
ARG VITE_GIT_URL=https://git.kropa.tech/lotherk/deardiary
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm install RUN npm install
COPY frontend ./ COPY frontend ./
ENV VITE_GIT_URL=$VITE_GIT_URL
RUN npm run build RUN npm run build
FROM oven/bun:1.1-alpine AS runner FROM oven/bun:1.1-alpine AS runner

View File

@@ -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
View 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;"]

View File

@@ -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"

View File

@@ -4,7 +4,7 @@ generator client {
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
url = env("DATABASE_URL") url = env("BACKEND_DATABASE_URL")
} }
model User { model User {
@@ -104,6 +104,7 @@ model Settings {
timezone String @default("UTC") timezone String @default("UTC")
providerSettings String? providerSettings String?
journalContextDays Int @default(10) journalContextDays Int @default(10)
useSystemDefault Boolean @default(true)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }

View File

@@ -8,19 +8,35 @@ import { createHash, randomBytes } from 'crypto';
import * as jose from 'jose'; import * as jose from 'jose';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createAIProvider } from './services/ai/provider'; import { createAIProvider } from './services/ai/provider';
import { eventsRoutes } from './routes/events';
const app = new Hono(); 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('*', logger());
app.use('*', cors({ app.use('*', cors({
origin: envVars.CORS_ORIGIN || '*', origin: envVars.BACKEND_CORS_ORIGIN || '*',
credentials: true, credentials: true,
})); }));
const prisma = new PrismaClient({ 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) => { 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); 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 }) const token = await new jose.SignJWT({ userId: user.id })
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })
.setIssuedAt() .setIssuedAt()
@@ -117,9 +133,9 @@ app.post('/api/v1/auth/login', async (c) => {
app.get('/api/v1/server-info', async (c) => { app.get('/api/v1/server-info', async (c) => {
return c.json({ return c.json({
data: { data: {
version: envVars.VERSION || '0.1.0', version: envVars.BACKEND_VERSION || '0.1.0',
registrationEnabled: envVars.REGISTRATION_ENABLED !== 'false', registrationEnabled: envVars.BACKEND_REGISTRATION_ENABLED !== 'false',
appName: envVars.APP_NAME || 'DearDiary', appName: envVars.BACKEND_APP_NAME || 'DearDiary',
}, },
error: null error: null
}); });
@@ -132,7 +148,7 @@ app.post('/api/v1/auth/api-key', async (c) => {
} }
const token = authHeader.slice(7); 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; let userId: string;
try { try {
@@ -293,22 +309,7 @@ app.get('/api/v1/search', async (c) => {
} }
}); });
app.delete('/api/v1/events/:id', async (c) => { app.route('/api/v1', eventsRoutes);
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 });
});
// Journal routes // Journal routes
app.post('/api/v1/journal/generate/:date', async (c) => { 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 providerConfig = providerSettings[provider] || {};
const apiKey = providerConfig.apiKey || settings?.aiApiKey; 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); 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 // Settings routes
app.get('/api/v1/settings', async (c) => { app.get('/api/v1/settings', async (c) => {
const userId = await getUserId(c); const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
let settings = await prisma.settings.findUnique({ where: { userId } }); let settings = await getUserSettings(userId);
if (!settings) {
settings = await prisma.settings.create({ data: { userId } });
}
const oldDefaultLength = 400; const oldDefaultLength = 400;
if (settings.journalPrompt && settings.journalPrompt.length > oldDefaultLength) { 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); if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const body = await c.req.json(); 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> = {}; const data: Record<string, unknown> = {};
if (aiProvider !== undefined) data.aiProvider = aiProvider; 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 (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 (journalPrompt !== undefined) data.journalPrompt = journalPrompt === '' ? null : journalPrompt;
if (language !== undefined) data.language = language; if (language !== undefined) data.language = language;
if (providerSettings !== undefined) data.providerSettings = JSON.stringify(providerSettings); if (providerSettings !== undefined) data.providerSettings = JSON.stringify(providerSettings);
if (journalContextDays !== undefined) data.journalContextDays = journalContextDays; 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({ const settings = await prisma.settings.upsert({
where: { userId }, where: { userId },
create: { userId, ...data }, create: { userId, ...getDefaultSettings(), ...data },
update: data, update: data,
}); });
@@ -1030,10 +1056,7 @@ app.post('/api/v1/account/reset', async (c) => {
await prisma.settings.update({ await prisma.settings.update({
where: { userId }, where: { userId },
data: { data: {
aiProvider: 'groq', ...getDefaultSettings(),
aiApiKey: null,
aiModel: 'llama-3.3-70b-versatile',
aiBaseUrl: null,
journalPrompt: null, journalPrompt: null,
language: 'en', language: 'en',
timezone: 'UTC', timezone: 'UTC',
@@ -1089,7 +1112,7 @@ app.post('/api/v1/account/password', async (c) => {
}); });
app.post('/api/v1/auth/register', 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) { if (!registrationEnabled) {
return c.json({ data: null, error: { code: 'REGISTRATION_DISABLED', message: 'Registration is currently disabled' } }, 403); 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() { async function createDefaultUser() {
const defaultEmail = envVars.DEFAULT_USER_EMAIL; const defaultEmail = envVars.BACKEND_DEFAULT_USER_EMAIL;
const defaultPassword = envVars.DEFAULT_USER_PASSWORD; const defaultPassword = envVars.BACKEND_DEFAULT_USER_PASSWORD;
if (!defaultEmail || !defaultPassword) { 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; 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}`); console.log(`Starting DearDiary API on port ${port}`);
setupFTS().then(() => { setupFTS().then(() => {

View File

@@ -6,10 +6,19 @@ export interface HonoEnv {
prisma: PrismaClient; prisma: PrismaClient;
}; };
Bindings: { Bindings: {
DATABASE_URL: string; BACKEND_DATABASE_URL: string;
JWT_SECRET: string; BACKEND_JWT_SECRET: string;
MEDIA_DIR: string; BACKEND_MEDIA_DIR: string;
PORT: string; BACKEND_PORT: string;
CORS_ORIGIN: 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;
}; };
} }

View File

@@ -34,6 +34,32 @@ export async function authMiddleware(c: Context<HonoEnv>, next: Next) {
export function authenticate() { export function authenticate() {
return async (c: Context<HonoEnv>, next: Next) => { 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();
}; };
} }

View File

@@ -59,7 +59,7 @@ authRoutes.post('/login', async (c) => {
} }
const prisma = c.get('prisma'); 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 } }); const user = await prisma.user.findUnique({ where: { email } });
if (!user) { if (!user) {
@@ -88,7 +88,7 @@ authRoutes.post('/api-key', async (c) => {
} }
const token = authHeader.slice(7); 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; let userId: string;
try { try {

View File

@@ -1,12 +1,15 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { HonoEnv } from '../lib/types'; import { HonoEnv } from '../lib/types';
import { authenticate } from '../middleware/auth';
export const eventsRoutes = new Hono<HonoEnv>(); export const eventsRoutes = new Hono<HonoEnv>();
eventsRoutes.post('/', async (c) => { eventsRoutes.use(authenticate());
eventsRoutes.post('/events', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');
const prisma = c.get('prisma'); 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 body = await c.req.json();
const { date, type, content, metadata, latitude, longitude, placeName } = body; 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); 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 userId = c.get('userId');
const { id } = c.req.param(); const { id } = c.req.param();
const prisma = c.get('prisma'); const prisma = c.get('prisma');
@@ -58,7 +61,7 @@ eventsRoutes.get('/:id', async (c) => {
return c.json({ data: event, error: null }); return c.json({ data: event, error: null });
}); });
eventsRoutes.put('/:id', async (c) => { eventsRoutes.put('/events/:id', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');
const { id } = c.req.param(); const { id } = c.req.param();
const prisma = c.get('prisma'); const prisma = c.get('prisma');
@@ -97,7 +100,7 @@ eventsRoutes.put('/:id', async (c) => {
return c.json({ data: event, error: null }); return c.json({ data: event, error: null });
}); });
eventsRoutes.delete('/:id', async (c) => { eventsRoutes.delete('/events/:id', async (c) => {
const userId = c.get('userId'); const userId = c.get('userId');
const { id } = c.req.param(); const { id } = c.req.param();
const prisma = c.get('prisma'); const prisma = c.get('prisma');
@@ -120,11 +123,11 @@ eventsRoutes.delete('/:id', async (c) => {
return c.json({ data: { deleted: true }, error: null }); 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 userId = c.get('userId');
const { id } = c.req.param(); const { id } = c.req.param();
const prisma = c.get('prisma'); 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({ const event = await prisma.event.findFirst({
where: { id, userId }, where: { id, userId },
@@ -157,11 +160,11 @@ eventsRoutes.post('/:id/photo', async (c) => {
return c.json({ data: { mediaPath: mediaUrl }, error: null }, 201); 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 userId = c.get('userId');
const { id } = c.req.param(); const { id } = c.req.param();
const prisma = c.get('prisma'); 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({ const event = await prisma.event.findFirst({
where: { id, userId }, where: { id, userId },

View File

@@ -40,6 +40,7 @@ import { AnthropicProvider } from './anthropic';
import { OllamaProvider } from './ollama'; import { OllamaProvider } from './ollama';
import { LMStudioProvider } from './lmstudio'; import { LMStudioProvider } from './lmstudio';
import { GroqProvider } from './groq'; import { GroqProvider } from './groq';
import { XAIProvider } from './xai';
import { CustomProvider } from './custom'; import { CustomProvider } from './custom';
export function createAIProvider(config: AIProviderConfig): AIProvider { export function createAIProvider(config: AIProviderConfig): AIProvider {
@@ -56,6 +57,8 @@ export function createAIProvider(config: AIProviderConfig): AIProvider {
return new LMStudioProvider(config, settings); return new LMStudioProvider(config, settings);
case 'groq': case 'groq':
return new GroqProvider(config, settings); return new GroqProvider(config, settings);
case 'xai':
return new XAIProvider(config, settings);
case 'custom': case 'custom':
return new CustomProvider(config, settings); return new CustomProvider(config, settings);
default: default:

View 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;
}
}
}

View File

@@ -1,37 +1,46 @@
services: 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: app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
- VITE_GIT_URL=${GIT_URL:-https://git.kropa.tech/lotherk/deardiary}
ports: ports:
- "3000:3000" - "3000:80"
- "5173:80"
environment: environment:
- DATABASE_URL=file:/data/deardiary.db - BACKEND_DATABASE_URL=file:/data/deardiary.db
- MEDIA_DIR=/data/media - BACKEND_MEDIA_DIR=/data/media
- JWT_SECRET=${JWT_SECRET:-change-me-in-production} - BACKEND_JWT_SECRET=${BACKEND_JWT_SECRET:-change-me-in-production}
- PORT=3000 - BACKEND_PORT=3001
- CORS_ORIGIN=${CORS_ORIGIN:-*} - BACKEND_CORS_ORIGIN=${BACKEND_CORS_ORIGIN:-*}
- DEFAULT_USER_EMAIL=${DEFAULT_USER_EMAIL:-} - BACKEND_REGISTRATION_ENABLED=${BACKEND_REGISTRATION_ENABLED:-false}
- DEFAULT_USER_PASSWORD=${DEFAULT_USER_PASSWORD:-} - 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: volumes:
- deardiary_data:/data - deardiary_data:/data
restart: unless-stopped restart: unless-stopped
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
docs:
build:
context: .
dockerfile: Dockerfile.docs
ports:
- "4000:80"
restart: unless-stopped
volumes: volumes:
deardiary_data: deardiary_data:

View 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;'

View File

@@ -81,7 +81,7 @@ function Footer({ appName = 'DearDiary' }: { appName?: string }) {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
return ( return (
<footer className={`py-6 text-center text-sm text-slate-500 ${resolvedTheme === 'dark' ? 'border-t border-slate-800' : 'border-t border-slate-200'}`}> <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> </footer>
); );
} }

View File

@@ -19,9 +19,30 @@ export default function QuickAddWidget({ isOpen, onClose }: Props) {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
checkDiaryStatus(); checkDiaryStatus();
setTimeout(() => inputRef.current?.focus(), 50);
} }
}, [isOpen]); }, [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 checkDiaryStatus = async () => {
const res = await api.getDay(today); const res = await api.getDay(today);
if (res.data) { if (res.data) {

View File

@@ -221,7 +221,7 @@ export interface Task {
} }
export interface Settings { export interface Settings {
aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq'; aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio' | 'groq' | 'xai' | 'custom';
aiApiKey?: string; aiApiKey?: string;
aiModel: string; aiModel: string;
aiBaseUrl?: string; aiBaseUrl?: string;
@@ -229,10 +229,13 @@ export interface Settings {
language: string; language: string;
timezone: string; timezone: string;
journalContextDays: number; journalContextDays: number;
useSystemDefault: boolean;
providerSettings?: Record<string, { providerSettings?: Record<string, {
apiKey?: string; apiKey?: string;
model?: string; model?: string;
baseUrl?: string; baseUrl?: string;
headers?: Record<string, string>;
parameters?: Record<string, unknown>;
}>; }>;
} }

View File

@@ -7,6 +7,8 @@ interface ProviderSettings {
apiKey?: string; apiKey?: string;
model?: string; model?: string;
baseUrl?: string; baseUrl?: string;
headers?: Record<string, string>;
parameters?: Record<string, unknown>;
} }
interface FullSettings extends Settings { interface FullSettings extends Settings {
@@ -15,15 +17,21 @@ interface FullSettings extends Settings {
const DEFAULT_MODELS: Record<string, string> = { const DEFAULT_MODELS: Record<string, string> = {
groq: 'llama-3.3-70b-versatile', groq: 'llama-3.3-70b-versatile',
openai: 'gpt-4o', openai: 'gpt-5-nano',
anthropic: 'claude-3-5-sonnet-20241022', anthropic: 'claude-3-5-sonnet-20241022',
ollama: 'llama3.2', ollama: 'llama3.2',
lmstudio: 'local-model', 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> = { const DEFAULT_BASE_URLS: Record<string, string> = {
ollama: 'http://localhost:11434', ollama: 'http://localhost:11434',
lmstudio: 'http://localhost:1234/v1', lmstudio: 'http://localhost:1234/v1',
custom: 'https://your-api.example.com/v1',
}; };
export default function SettingsPage() { export default function SettingsPage() {
@@ -34,6 +42,7 @@ export default function SettingsPage() {
language: 'en', language: 'en',
timezone: 'UTC', timezone: 'UTC',
journalContextDays: 10, journalContextDays: 10,
useSystemDefault: true,
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -80,6 +89,9 @@ export default function SettingsPage() {
if (!data.providerSettings) { if (!data.providerSettings) {
data.providerSettings = {}; data.providerSettings = {};
} }
if (data.useSystemDefault === undefined) {
data.useSystemDefault = true;
}
const provider = data.aiProvider || 'groq'; const provider = data.aiProvider || 'groq';
const providerData = data.providerSettings[provider] || {}; const providerData = data.providerSettings[provider] || {};
data.aiApiKey = providerData.apiKey; data.aiApiKey = providerData.apiKey;
@@ -158,6 +170,7 @@ export default function SettingsPage() {
await api.updateSettings({ await api.updateSettings({
aiProvider: settings.aiProvider, aiProvider: settings.aiProvider,
providerSettings: newProviderSettings, providerSettings: newProviderSettings,
useSystemDefault: settings.useSystemDefault,
}); });
setSaving(false); setSaving(false);
setSaved(true); setSaved(true);
@@ -448,13 +461,33 @@ export default function SettingsPage() {
> >
<option value="groq">Groq (Free, Fast)</option> <option value="groq">Groq (Free, Fast)</option>
<option value="openai">OpenAI (GPT-4)</option> <option value="openai">OpenAI (GPT-4)</option>
<option value="xai">xAI (Grok)</option>
<option value="anthropic">Anthropic (Claude)</option> <option value="anthropic">Anthropic (Claude)</option>
<option value="ollama">Ollama (Local)</option> <option value="ollama">Ollama (Local)</option>
<option value="lmstudio">LM Studio (Local)</option> <option value="lmstudio">LM Studio (Local)</option>
<option value="custom">Custom (OpenAI-compatible)</option>
</select> </select>
</div> </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> <div>
<label className="block text-sm text-slate-400 mb-1">API Key</label> <label className="block text-sm text-slate-400 mb-1">API Key</label>
<input <input
@@ -467,7 +500,7 @@ export default function SettingsPage() {
</div> </div>
)} )}
{(provider === 'ollama' || provider === 'lmstudio') && ( {!settings.useSystemDefault && PROVIDERS_WITH_BASE_URL.includes(provider) && (
<div> <div>
<label className="block text-sm text-slate-400 mb-1">Base URL</label> <label className="block text-sm text-slate-400 mb-1">Base URL</label>
<input <input
@@ -480,6 +513,7 @@ export default function SettingsPage() {
</div> </div>
)} )}
{!settings.useSystemDefault && (
<div> <div>
<label className="block text-sm text-slate-400 mb-1">Model</label> <label className="block text-sm text-slate-400 mb-1">Model</label>
<input <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" className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
/> />
</div> </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"> <div className="flex gap-3 items-center">
<button <button

View File

@@ -1,7 +1,10 @@
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, 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'),
},
};
}); });

View File

@@ -16,7 +16,7 @@ server {
} }
location /api/ { location /api/ {
proxy_pass http://127.0.0.1:3000; proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';

View File

@@ -18,8 +18,8 @@
<div class="nav-links"> <div class="nav-links">
<a href="#features">Features</a> <a href="#features">Features</a>
<a href="docs/">Docs</a> <a href="docs/">Docs</a>
<a href="https://github.com/lotherk/deardiary" target="_blank">GitHub</a> <a href="${GIT_URL}" target="_blank">Git Repository</a>
<a href="#try" class="btn btn-primary">Get Started</a> <a href="${APP_URL}" class="btn btn-primary">Join Free Alpha</a>
</div> </div>
</div> </div>
</nav> </nav>
@@ -29,11 +29,11 @@
<h1>Your AI-Powered<br><span class="gradient-text">Daily Journal</span></h1> <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> <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"> <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> <a href="docs/" class="btn btn-secondary btn-large">Read Docs</a>
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
<span>v0.1.0</span> <span>v0.1.1</span>
<span></span> <span></span>
<span>Docker</span> <span>Docker</span>
<span></span> <span></span>
@@ -113,11 +113,16 @@
<div class="container"> <div class="container">
<div class="try-content"> <div class="try-content">
<h2>Ready to start journaling?</h2> <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"> <div class="try-code">
<code>git clone https://github.com/lotherk/deardiary.git && cd deardiary && docker compose up -d</code> <code>git clone https://github.com/lotherk/deardiary.git && cd deardiary && docker compose up -d</code>
</div> </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>
</div> </div>
</section> </section>
@@ -130,6 +135,7 @@
<a href="#features">Features</a> <a href="#features">Features</a>
<a href="docs/">Documentation</a> <a href="docs/">Documentation</a>
<a href="#how">How it Works</a> <a href="#how">How it Works</a>
<a href="${APP_URL}">Free Alpha</a>
</div> </div>
<div class="footer-col"> <div class="footer-col">
<h4>Resources</h4> <h4>Resources</h4>
@@ -139,9 +145,9 @@
</div> </div>
<div class="footer-col"> <div class="footer-col">
<h4>Project</h4> <h4>Project</h4>
<a href="https://github.com/lotherk/deardiary" target="_blank">GitHub</a> <a href="${GIT_URL}" target="_blank">Source Code</a>
<a href="https://github.com/lotherk/deardiary/issues" target="_blank">Issues</a> <a href="${GIT_URL}/issues" target="_blank">Issues</a>
<a href="https://github.com/lotherk/deardiary/releases" target="_blank">Releases</a> <a href="${GIT_URL}/releases" target="_blank">Releases</a>
</div> </div>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">