full working application and initial commit

This commit is contained in:
2026-06-04 18:39:39 +02:00
commit 8524179793
86 changed files with 10107 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
import { redirect, type Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import {
createConvexAuthHooks,
createRouteMatcher
} from '@mmailaender/convex-auth-svelte/sveltekit/server';
const isProtectedRoute = createRouteMatcher([
'/dashboard{/*rest}',
'/entries{/*rest}',
'/settings{/*rest}'
]);
const { handleAuth, isAuthenticated } = createConvexAuthHooks();
const protectRoutes: Handle = async ({ event, resolve }) => {
if (isProtectedRoute(event.url.pathname) && !(await isAuthenticated(event))) {
throw redirect(
302,
`/signin?redirectTo=${encodeURIComponent(event.url.pathname + event.url.search)}`
);
}
return resolve(event);
};
export const handle = sequence(handleAuth, protectRoutes);
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
let { size = 36 }: { size?: number } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 64 64"
fill="none"
role="img"
aria-label="Journaley logo"
>
<rect x="12" y="10" width="36" height="44" rx="7" fill="currentColor" opacity="0.14" />
<path
d="M20 14h24a8 8 0 0 1 8 8v30a3 3 0 0 1-3 3H23a11 11 0 0 1-11-11V22a8 8 0 0 1 8-8Z"
fill="#c86432"
/>
<path d="M23 21h17M23 29h21M23 37h13" stroke="#fff7ed" stroke-width="4" stroke-linecap="round" />
<path d="M39 43c7-1 11-5 12-12 5 7 1 16-8 18-4 1-7 0-10-2 1-2 3-3 6-4Z" fill="#f5c36b" />
<path d="M34 47c4-5 9-8 17-10" stroke="#7c3f22" stroke-width="3" stroke-linecap="round" />
</svg>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import { browser } from '$app/environment';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
let { markdown = '' }: { markdown?: string } = $props();
marked.use({
gfm: true,
breaks: true
});
function serverSafe(html: string) {
return html
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
.replace(/\son\w+="[^"]*"/gi, '')
.replace(/\son\w+='[^']*'/gi, '');
}
const html = $derived.by(() => {
const rendered = marked.parse(markdown || '*Nothing written yet.*') as string;
return browser ? DOMPurify.sanitize(rendered) : serverSafe(rendered);
});
</script>
<div class="markdown-body max-w-none">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html html}
</div>
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Moon, Sun } from '@lucide/svelte';
let theme = $state<'system' | 'light' | 'dark'>('system');
function applyTheme(next: typeof theme) {
theme = next;
if (typeof document === 'undefined') return;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle(
'dark',
next === 'dark' || (next === 'system' && prefersDark)
);
localStorage.setItem('journaley-theme', next);
}
onMount(() => {
const saved = localStorage.getItem('journaley-theme') as typeof theme | null;
applyTheme(saved ?? 'system');
});
</script>
<button
type="button"
class="app-button secondary px-3"
aria-label="Toggle theme"
onclick={() => applyTheme(theme === 'dark' ? 'light' : 'dark')}
>
{#if theme === 'dark'}
<Sun size={16} />
{:else}
<Moon size={16} />
{/if}
</button>
+1
View File
@@ -0,0 +1 @@
// Reexport your entry components here
+24
View File
@@ -0,0 +1,24 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatEntryDate(
value: string | number | Date,
options?: Intl.DateTimeFormatOptions
) {
const date = typeof value === 'string' || typeof value === 'number' ? new Date(value) : value;
return new Intl.DateTimeFormat('en', {
month: 'short',
day: 'numeric',
year: 'numeric',
...options
}).format(date);
}
export function todayISO() {
return new Date().toISOString().slice(0, 10);
}
+10
View File
@@ -0,0 +1,10 @@
import { createConvexAuthHandlers } from '@mmailaender/convex-auth-svelte/sveltekit/server';
import type { LayoutServerLoad } from './$types.js';
const { getAuthState } = createConvexAuthHandlers();
export const load: LayoutServerLoad = async (event) => {
return {
authState: await getAuthState(event)
};
};
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import { page } from '$app/state';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { BookOpenText, LayoutDashboard, LogOut, PenLine, Settings } from '@lucide/svelte';
import { setupConvexAuth, useAuth } from '@mmailaender/convex-auth-svelte/sveltekit';
import Logo from '$lib/components/Logo.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import './layout.css';
let { children, data } = $props();
setupConvexAuth({
getServerState: () => data.authState,
convexUrl: PUBLIC_CONVEX_URL,
storage: 'localStorage',
storageNamespace: 'journaley'
});
const auth = useAuth();
const isAuthenticated = $derived(auth.isAuthenticated);
const isLoading = $derived(auth.isLoading);
const isAuthRoute = $derived(page.url.pathname.startsWith('/signin'));
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/entries', label: 'Daily Entries', icon: BookOpenText },
{ href: '/settings', label: 'Settings', icon: Settings }
];
</script>
{#if isLoading && !isAuthRoute}
<div class="flex min-h-screen items-center justify-center p-6">
<div class="app-card max-w-sm p-6 text-center">
<div class="mx-auto mb-4 text-primary"><Logo size={44} /></div>
<p class="text-sm text-muted-foreground">Opening your journal...</p>
</div>
</div>
{:else if isAuthenticated && !isAuthRoute}
<div class="min-h-screen lg:grid lg:grid-cols-[17rem_1fr]">
<aside class="hidden border-r bg-card/78 px-5 py-6 backdrop-blur lg:block">
<a href="/dashboard" class="mb-9 flex items-center gap-3">
<span class="text-primary"><Logo /></span>
<span>
<span class="block text-lg font-bold tracking-tight">Journaley</span>
<span class="text-xs text-muted-foreground">Write, tag, find.</span>
</span>
</a>
<nav class="space-y-2">
{#each navItems as item (item.href)}
{@const Icon = item.icon}
<a
href={item.href}
class="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-semibold transition hover:bg-secondary {page.url.pathname.startsWith(
item.href
)
? 'bg-secondary text-foreground'
: 'text-muted-foreground'}"
>
<Icon size={18} />
{item.label}
</a>
{/each}
</nav>
<div class="mt-8 rounded-lg border bg-background/70 p-4">
<div class="mb-2 flex items-center gap-2 text-sm font-semibold">
<PenLine size={16} />
Today counts
</div>
<p class="text-sm text-muted-foreground">
A few honest lines are enough. Keep the thread alive.
</p>
</div>
</aside>
<div class="min-w-0">
<header
class="sticky top-0 z-20 flex items-center justify-between border-b bg-background/82 px-4 py-3 backdrop-blur lg:px-8"
>
<a href="/dashboard" class="flex items-center gap-2 font-bold lg:hidden">
<span class="text-primary"><Logo size={30} /></span>
Journaley
</a>
<nav class="hidden items-center gap-1 lg:flex">
{#each navItems as item (item.href)}
<a
href={item.href}
class="rounded-md px-3 py-2 text-sm font-semibold text-muted-foreground transition hover:bg-secondary hover:text-foreground"
>
{item.label}
</a>
{/each}
</nav>
<div class="flex items-center gap-2">
<ThemeToggle />
<button class="app-button ghost" type="button" onclick={() => auth.signOut()}>
<LogOut size={16} />
<span class="hidden sm:inline">Sign out</span>
</button>
</div>
</header>
<main class="mx-auto w-full max-w-7xl px-4 py-6 lg:px-8 lg:py-8">
{@render children()}
</main>
</div>
</div>
{:else}
{@render children()}
{/if}
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import { createConvexAuthHandlers } from '@mmailaender/convex-auth-svelte/sveltekit/server';
import type { PageServerLoad } from './$types.js';
const { isAuthenticated } = createConvexAuthHandlers();
export const load: PageServerLoad = async (event) => {
throw redirect(302, (await isAuthenticated(event)) ? '/dashboard' : '/signin');
};
+1
View File
@@ -0,0 +1 @@
<p>Loading Journaley...</p>
+133
View File
@@ -0,0 +1,133 @@
<script lang="ts">
import { api } from '$convex/_generated/api.js';
import { BookOpenText, CalendarDays, Flame, Hash, Sparkles } from '@lucide/svelte';
import { useQuery } from 'convex-svelte';
import { formatEntryDate } from '$lib/utils.js';
const summaryQuery = useQuery(api.analytics.summary, {});
const recentQuery = useQuery(api.entries.recent, { limit: 6 });
const summary = $derived(summaryQuery.data);
const recentEntries = $derived(recentQuery.data ?? []);
const maxDaily = $derived(Math.max(1, ...(summary?.dailySeries.map((day) => day.count) ?? [1])));
</script>
<svelte:head>
<title>Dashboard | Journaley</title>
</svelte:head>
<div class="space-y-7">
<section class="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<p class="text-sm font-semibold tracking-[0.2em] text-primary uppercase">Dashboard</p>
<h1 class="mt-2 text-3xl font-bold tracking-tight md:text-4xl">Your journal at a glance</h1>
<p class="mt-2 max-w-2xl text-muted-foreground">
Recent reflections, trends, and gentle signals from your writing rhythm.
</p>
</div>
<a class="app-button" href="/entries">New entry</a>
</section>
{#if summaryQuery.isLoading}
<div class="app-card p-6 text-sm text-muted-foreground">Loading your journal...</div>
{:else if summary}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="app-card p-5">
<BookOpenText class="mb-4 text-primary" size={24} />
<p class="text-sm text-muted-foreground">Total entries</p>
<p class="mt-1 text-3xl font-bold">{summary.totalEntries}</p>
</div>
<div class="app-card p-5">
<CalendarDays class="mb-4 text-primary" size={24} />
<p class="text-sm text-muted-foreground">This week</p>
<p class="mt-1 text-3xl font-bold">{summary.entriesThisWeek}</p>
</div>
<div class="app-card p-5">
<Flame class="mb-4 text-primary" size={24} />
<p class="text-sm text-muted-foreground">Current streak</p>
<p class="mt-1 text-3xl font-bold">{summary.currentStreak} days</p>
</div>
<div class="app-card p-5">
<Sparkles class="mb-4 text-primary" size={24} />
<p class="text-sm text-muted-foreground">Avg. words</p>
<p class="mt-1 text-3xl font-bold">{summary.averageWords}</p>
</div>
</section>
<section class="grid gap-5 xl:grid-cols-[1.35fr_0.85fr]">
<div class="app-card p-5">
<div class="mb-5 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold">30-day activity</h2>
<p class="text-sm text-muted-foreground">{summary.insight}</p>
</div>
</div>
<div class="flex h-48 items-end gap-1.5">
{#each summary.dailySeries as day (day.date)}
<div class="flex flex-1 flex-col items-center gap-2">
<div
class="w-full rounded-sm bg-primary/80"
style={`height: ${Math.max(8, (day.count / maxDaily) * 170)}px`}
title={`${day.date}: ${day.count} entries`}
></div>
</div>
{/each}
</div>
</div>
<div class="app-card p-5">
<h2 class="text-lg font-bold">Top tags</h2>
<div class="mt-4 space-y-3">
{#if summary.topTags.length}
{#each summary.topTags as tag (tag.name)}
<div class="flex items-center justify-between rounded-md bg-secondary/65 px-3 py-2">
<span class="flex items-center gap-2 text-sm font-semibold"
><Hash size={14} /> {tag.name}</span
>
<span class="text-sm text-muted-foreground">{tag.count}</span>
</div>
{/each}
{:else}
<p class="text-sm text-muted-foreground">Tags will appear here after you use them.</p>
{/if}
</div>
</div>
</section>
{/if}
<section class="app-card p-5">
<div class="mb-5 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold">Recent entries</h2>
<p class="text-sm text-muted-foreground">Pick up where your thoughts left off.</p>
</div>
<a class="text-sm font-semibold text-primary" href="/entries">View all</a>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each recentEntries as entry (entry._id)}
<a
class="rounded-lg border bg-background/60 p-4 transition hover:border-primary/50"
href="/entries"
>
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="line-clamp-1 font-bold">{entry.title}</h3>
<span
class="rounded-sm bg-accent px-2 py-1 text-xs font-semibold text-accent-foreground capitalize"
>
{entry.mood}
</span>
</div>
<p class="text-xs text-muted-foreground">{formatEntryDate(entry.entryDate)}</p>
<p class="mt-3 line-clamp-3 text-sm text-muted-foreground">{entry.plainText}</p>
</a>
{:else}
<div
class="rounded-lg border border-dashed p-6 text-sm text-muted-foreground md:col-span-2 xl:col-span-3"
>
No entries yet. Create your first daily reflection from the entries page.
</div>
{/each}
</div>
</section>
</div>
+436
View File
@@ -0,0 +1,436 @@
<script lang="ts">
import { api } from '$convex/_generated/api.js';
import type { Doc } from '$convex/_generated/dataModel.js';
import { Archive, Edit3, Plus, Search, Star, Trash2, X } from '@lucide/svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
import MarkdownPreview from '$lib/components/MarkdownPreview.svelte';
import { formatEntryDate, todayISO } from '$lib/utils.js';
type Mood = 'joyful' | 'calm' | 'neutral' | 'tired' | 'stressed' | 'sad' | 'angry' | 'grateful';
type Sort = 'newest' | 'oldest' | 'updated' | 'title';
type EntryWithTags = Doc<'entries'> & { tags: Doc<'tags'>[] };
const moods: Mood[] = [
'joyful',
'calm',
'neutral',
'tired',
'stressed',
'sad',
'angry',
'grateful'
];
const markdownPlaceholder = `# Today
Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
const client = useConvexClient();
let search = $state('');
let moodFilter = $state<'all' | Mood>('all');
let selectedTagSlugs = $state<string[]>([]);
let dateFrom = $state('');
let dateTo = $state('');
let includeArchived = $state(false);
let sort = $state<Sort>('newest');
let editorOpen = $state(false);
let previewMode = $state<'write' | 'preview' | 'split'>('split');
let editingEntry = $state<EntryWithTags | null>(null);
let title = $state('');
let body = $state('');
let mood = $state<Mood>('neutral');
let entryDate = $state(todayISO());
let tagsText = $state('');
let pinned = $state(false);
let archived = $state(false);
let saving = $state(false);
let formError = $state('');
const entriesQuery = useQuery(
api.entries.list,
() => ({
search: search.trim() || undefined,
mood: moodFilter === 'all' ? undefined : moodFilter,
tagSlugs: selectedTagSlugs,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
includeArchived,
sort,
limit: 100
}),
{ keepPreviousData: true }
);
const tagsQuery = useQuery(api.tags.list, { limit: 100 });
const settingsQuery = useQuery(api.settings.get, {});
const entries = $derived(entriesQuery.data ?? []);
const allTags = $derived(tagsQuery.data ?? []);
function resetForm() {
editingEntry = null;
title = '';
body = '';
mood = (settingsQuery.data?.defaultMood ?? 'neutral') as Mood;
entryDate = todayISO();
tagsText = '';
pinned = false;
archived = false;
previewMode = (settingsQuery.data?.editorMode ?? 'split') as typeof previewMode;
formError = '';
}
function openCreate() {
resetForm();
editorOpen = true;
}
function openEdit(entry: EntryWithTags) {
editingEntry = entry;
title = entry.title;
body = entry.body;
mood = entry.mood as Mood;
entryDate = entry.entryDate;
tagsText = entry.tagNames.join(', ');
pinned = entry.pinned;
archived = entry.archived;
previewMode = (settingsQuery.data?.editorMode ?? 'split') as typeof previewMode;
formError = '';
editorOpen = true;
}
function tagNames() {
return tagsText
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
}
async function saveEntry() {
formError = '';
saving = true;
try {
if (editingEntry) {
await client.mutation(api.entries.update, {
entryId: editingEntry._id,
title,
body,
mood,
entryDate,
tagNames: tagNames(),
pinned,
archived
});
} else {
await client.mutation(api.entries.create, {
title,
body,
mood,
entryDate,
tagNames: tagNames(),
pinned
});
}
editorOpen = false;
} catch (caught) {
formError = caught instanceof Error ? caught.message : 'Unable to save entry.';
} finally {
saving = false;
}
}
async function removeEntry(entry: EntryWithTags) {
if (!confirm(`Delete "${entry.title}"? This cannot be undone.`)) return;
await client.mutation(api.entries.remove, { entryId: entry._id });
}
function toggleTag(slug: string) {
selectedTagSlugs = selectedTagSlugs.includes(slug)
? selectedTagSlugs.filter((value) => value !== slug)
: [...selectedTagSlugs, slug];
}
</script>
<svelte:head>
<title>Daily Entries | Journaley</title>
</svelte:head>
<div class="space-y-6">
<section class="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<p class="text-sm font-semibold tracking-[0.2em] text-primary uppercase">Daily entries</p>
<h1 class="mt-2 text-3xl font-bold tracking-tight md:text-4xl">
Write and revisit everything
</h1>
<p class="mt-2 max-w-2xl text-muted-foreground">
Search the full text of your markdown entries, filter by mood and tags, and sort your
archive.
</p>
</div>
<button class="app-button" type="button" onclick={openCreate}
><Plus size={16} /> New entry</button
>
</section>
<section class="app-card space-y-4 p-4">
<div class="grid gap-3 lg:grid-cols-[1.5fr_0.7fr_0.7fr_0.7fr]">
<label class="relative block">
<Search class="absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground" size={17} />
<input
class="app-input pl-10"
placeholder="Search title, body, mood, or tag..."
bind:value={search}
/>
</label>
<select class="app-select" bind:value={moodFilter}>
<option value="all">All moods</option>
{#each moods as item (item)}
<option value={item}>{item}</option>
{/each}
</select>
<select class="app-select" bind:value={sort}>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="updated">Recently updated</option>
<option value="title">Title A-Z</option>
</select>
<label
class="flex items-center gap-2 rounded-md border bg-background/60 px-3 text-sm font-semibold"
>
<input type="checkbox" bind:checked={includeArchived} />
Show archived
</label>
</div>
<div class="grid gap-3 md:grid-cols-2">
<label class="space-y-1 text-sm font-semibold">
<span>From</span>
<input class="app-input" type="date" bind:value={dateFrom} />
</label>
<label class="space-y-1 text-sm font-semibold">
<span>To</span>
<input class="app-input" type="date" bind:value={dateTo} />
</label>
</div>
<div class="flex flex-wrap gap-2">
{#each allTags as tag (tag._id)}
<button
type="button"
class="rounded-sm border px-3 py-1.5 text-xs font-bold transition {selectedTagSlugs.includes(
tag.slug
)
? 'border-primary bg-primary text-primary-foreground'
: 'bg-background text-muted-foreground hover:text-foreground'}"
onclick={() => toggleTag(tag.slug)}
>
#{tag.name}
</button>
{/each}
</div>
</section>
<section class="grid gap-4 xl:grid-cols-2">
{#if entriesQuery.isLoading}
<div class="app-card p-6 text-sm text-muted-foreground">Loading entries...</div>
{:else}
{#each entries as entry (entry._id)}
<article class="app-card p-5">
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<div class="mb-2 flex flex-wrap items-center gap-2">
{#if entry.pinned}
<span
class="rounded-sm bg-accent px-2 py-1 text-xs font-bold text-accent-foreground"
>
Pinned
</span>
{/if}
{#if entry.archived}
<span
class="rounded-sm bg-muted px-2 py-1 text-xs font-bold text-muted-foreground"
>
Archived
</span>
{/if}
<span class="rounded-sm bg-secondary px-2 py-1 text-xs font-bold capitalize"
>{entry.mood}</span
>
</div>
<h2 class="text-xl font-bold tracking-tight">{entry.title}</h2>
<p class="mt-1 text-sm text-muted-foreground">{formatEntryDate(entry.entryDate)}</p>
</div>
<div class="flex gap-1">
<button
class="app-button ghost px-2"
type="button"
onclick={() => openEdit(entry)}
aria-label="Edit"
>
<Edit3 size={16} />
</button>
<button
class="app-button ghost px-2 text-destructive"
type="button"
onclick={() => removeEntry(entry)}
aria-label="Delete"
>
<Trash2 size={16} />
</button>
</div>
</div>
<p class="line-clamp-4 text-sm leading-6 text-muted-foreground">{entry.plainText}</p>
<div class="mt-4 flex flex-wrap gap-2">
{#each entry.tags as tag (tag._id)}
<span class="rounded-sm border bg-background px-2 py-1 text-xs font-semibold"
>#{tag.name}</span
>
{/each}
</div>
</article>
{:else}
<div class="app-card border-dashed p-8 text-center xl:col-span-2">
<h2 class="text-xl font-bold">No entries found</h2>
<p class="mt-2 text-sm text-muted-foreground">
Create a new entry or loosen the filters to find more reflections.
</p>
<button class="app-button mt-5" type="button" onclick={openCreate}>Create entry</button>
</div>
{/each}
{/if}
</section>
</div>
{#if editorOpen}
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/48 p-4 backdrop-blur-sm">
<div class="mx-auto my-6 w-full max-w-6xl rounded-lg border bg-card shadow-2xl">
<div class="flex items-center justify-between border-b p-4">
<div>
<h2 class="text-xl font-bold">{editingEntry ? 'Edit entry' : 'New daily entry'}</h2>
<p class="text-sm text-muted-foreground">{formatEntryDate(entryDate)}</p>
</div>
<button
class="app-button ghost px-2"
type="button"
onclick={() => (editorOpen = false)}
aria-label="Close"
>
<X size={18} />
</button>
</div>
<form
class="space-y-5 p-4 md:p-6"
onsubmit={(event) => (event.preventDefault(), saveEntry())}
>
<div class="grid gap-4 lg:grid-cols-[1fr_13rem_13rem]">
<label class="space-y-2">
<span class="text-sm font-semibold">Title</span>
<input
class="app-input text-lg font-bold"
bind:value={title}
placeholder="What happened today?"
required
/>
<span class="block text-xs text-muted-foreground">
Date auto-populated: {formatEntryDate(entryDate)}
</span>
</label>
<label class="space-y-2">
<span class="text-sm font-semibold">Mood</span>
<select class="app-select capitalize" bind:value={mood}>
{#each moods as item (item)}
<option value={item}>{item}</option>
{/each}
</select>
</label>
<label class="space-y-2">
<span class="text-sm font-semibold">Date</span>
<input class="app-input" type="date" bind:value={entryDate} />
</label>
</div>
<label class="space-y-2">
<span class="text-sm font-semibold">Tags</span>
<input class="app-input" bind:value={tagsText} placeholder="work, gratitude, health" />
<span class="text-xs text-muted-foreground"
>Separate tags with commas. New tags are created automatically.</span
>
</label>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex rounded-md border bg-background p-1">
{#each ['write', 'preview', 'split'] as mode (mode)}
<button
type="button"
class="rounded-sm px-3 py-1.5 text-sm font-semibold capitalize {previewMode === mode
? 'bg-secondary text-foreground'
: 'text-muted-foreground'}"
onclick={() => (previewMode = mode as typeof previewMode)}
>
{mode}
</button>
{/each}
</div>
<div class="flex gap-3 text-sm font-semibold">
<label class="flex items-center gap-2"
><input type="checkbox" bind:checked={pinned} /> <Star size={15} /> Pinned</label
>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={archived} />
<Archive size={15} /> Archived
</label>
</div>
</div>
<div class="grid gap-4 {previewMode === 'split' ? 'lg:grid-cols-2' : ''}">
{#if previewMode !== 'preview'}
<label class="space-y-2">
<span class="text-sm font-semibold">Markdown</span>
<textarea
class="app-textarea min-h-[28rem]"
bind:value={body}
placeholder={markdownPlaceholder}
></textarea>
</label>
{/if}
{#if previewMode !== 'write'}
<div class="space-y-2">
<span class="text-sm font-semibold">Preview</span>
<div class="min-h-[28rem] rounded-lg border bg-background/70 p-5">
<MarkdownPreview markdown={body} />
</div>
</div>
{/if}
</div>
{#if formError}
<p
class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
>
{formError}
</p>
{/if}
<div class="flex justify-end gap-3 border-t pt-4">
<button class="app-button secondary" type="button" onclick={() => (editorOpen = false)}
>Cancel</button
>
<button class="app-button" type="submit" disabled={saving}
>{saving ? 'Saving...' : 'Save entry'}</button
>
</div>
</form>
</div>
</div>
{/if}
+273
View File
@@ -0,0 +1,273 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@custom-variant dark (&:is(.dark *));
:root {
color-scheme: light;
--background: 38 38% 96%;
--foreground: 24 26% 12%;
--card: 38 44% 98%;
--card-foreground: 24 26% 12%;
--popover: 38 44% 98%;
--popover-foreground: 24 26% 12%;
--primary: 18 68% 45%;
--primary-foreground: 38 44% 98%;
--secondary: 35 42% 88%;
--secondary-foreground: 24 24% 18%;
--muted: 35 28% 90%;
--muted-foreground: 24 12% 43%;
--accent: 42 62% 82%;
--accent-foreground: 24 26% 14%;
--destructive: 0 72% 50%;
--destructive-foreground: 38 44% 98%;
--border: 32 25% 82%;
--input: 32 25% 82%;
--ring: 18 68% 45%;
--radius: 0.55rem;
}
.dark {
color-scheme: dark;
--background: 220 18% 9%;
--foreground: 36 28% 92%;
--card: 220 17% 12%;
--card-foreground: 36 28% 92%;
--popover: 220 17% 12%;
--popover-foreground: 36 28% 92%;
--primary: 26 82% 62%;
--primary-foreground: 220 18% 9%;
--secondary: 218 15% 18%;
--secondary-foreground: 36 28% 92%;
--muted: 218 15% 18%;
--muted-foreground: 35 14% 68%;
--accent: 32 32% 24%;
--accent-foreground: 36 28% 92%;
--destructive: 0 62% 48%;
--destructive-foreground: 36 28% 92%;
--border: 218 15% 22%;
--input: 218 15% 22%;
--ring: 26 82% 62%;
}
@theme inline {
--font-sans:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
* {
border-color: hsl(var(--border));
}
html {
background: hsl(var(--background));
}
body {
min-height: 100vh;
background:
radial-gradient(circle at top left, hsl(var(--accent) / 0.42), transparent 26rem),
hsl(var(--background));
color: hsl(var(--foreground));
font-family: var(--font-sans);
}
button,
input,
textarea,
select {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.app-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: hsl(var(--primary));
padding: 0.625rem 0.95rem;
color: hsl(var(--primary-foreground));
font-size: 0.875rem;
font-weight: 650;
line-height: 1;
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.app-button:hover {
transform: translateY(-1px);
}
.app-button.secondary {
border-color: hsl(var(--border));
background: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
}
.app-button.ghost {
border-color: transparent;
background: transparent;
color: hsl(var(--foreground));
}
.app-button.destructive {
background: hsl(var(--destructive));
color: hsl(var(--destructive-foreground));
}
.app-input,
.app-textarea,
.app-select {
width: 100%;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--input));
background: hsl(var(--background) / 0.72);
padding: 0.65rem 0.75rem;
color: hsl(var(--foreground));
outline: none;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
background-color 160ms ease;
}
.app-textarea {
min-height: 13rem;
resize: vertical;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, ui-monospace, monospace;
}
.app-input:focus,
.app-textarea:focus,
.app-select:focus {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.16);
}
.app-card {
border: 1px solid hsl(var(--border));
border-radius: var(--radius-lg);
background: hsl(var(--card) / 0.9);
box-shadow: 0 18px 50px hsl(24 26% 12% / 0.08);
}
.dark .app-card {
box-shadow: 0 18px 60px hsl(0 0% 0% / 0.28);
}
.markdown-body {
color: hsl(var(--foreground));
}
.markdown-body :where(h1, h2, h3) {
margin-top: 1.2em;
margin-bottom: 0.55em;
font-weight: 760;
letter-spacing: -0.025em;
}
.markdown-body h1 {
font-size: 1.75rem;
}
.markdown-body h2 {
font-size: 1.35rem;
}
.markdown-body h3 {
font-size: 1.1rem;
}
.markdown-body p,
.markdown-body ul,
.markdown-body ol,
.markdown-body blockquote,
.markdown-body pre,
.markdown-body table {
margin-bottom: 1rem;
}
.markdown-body a {
color: hsl(var(--primary));
text-decoration: underline;
text-underline-offset: 0.2em;
}
.markdown-body blockquote {
border-left: 3px solid hsl(var(--primary));
padding-left: 1rem;
color: hsl(var(--muted-foreground));
}
.markdown-body code {
border-radius: 0.35rem;
background: hsl(var(--muted));
padding: 0.12rem 0.3rem;
font-size: 0.9em;
}
.markdown-body pre {
overflow-x: auto;
border-radius: var(--radius-md);
background: hsl(220 18% 10%);
padding: 1rem;
color: hsl(36 28% 92%);
}
.markdown-body pre code {
background: transparent;
padding: 0;
color: inherit;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 1.25rem;
}
.markdown-body ul {
list-style: disc;
}
.markdown-body ol {
list-style: decimal;
}
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { api } from '$convex/_generated/api.js';
import { Save } from '@lucide/svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
type Theme = 'system' | 'light' | 'dark';
type Mood = 'joyful' | 'calm' | 'neutral' | 'tired' | 'stressed' | 'sad' | 'angry' | 'grateful';
type EditorMode = 'split' | 'write' | 'preview';
type DashboardRange = '7d' | '30d' | '90d';
const moods: Mood[] = [
'joyful',
'calm',
'neutral',
'tired',
'stressed',
'sad',
'angry',
'grateful'
];
const client = useConvexClient();
const settingsQuery = useQuery(api.settings.get, {});
let theme = $state<Theme>('system');
let defaultMood = $state<Mood>('neutral');
let editorMode = $state<EditorMode>('split');
let dashboardRange = $state<DashboardRange>('30d');
let loadedId = $state<string | null>(null);
let saving = $state(false);
let saved = $state(false);
let error = $state('');
$effect(() => {
const settings = settingsQuery.data;
if (!settings) return;
const nextId = [
settings.theme,
settings.defaultMood,
settings.editorMode,
settings.dashboardRange
].join(':');
if (loadedId === nextId) return;
theme = settings.theme;
defaultMood = settings.defaultMood;
editorMode = settings.editorMode;
dashboardRange = settings.dashboardRange;
loadedId = nextId;
});
function applyTheme(next: Theme) {
if (typeof document === 'undefined') return;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle(
'dark',
next === 'dark' || (next === 'system' && prefersDark)
);
localStorage.setItem('journaley-theme', next);
}
async function saveSettings() {
saving = true;
saved = false;
error = '';
try {
await client.mutation(api.settings.save, {
theme,
defaultMood,
editorMode,
dashboardRange
});
applyTheme(theme);
saved = true;
} catch (caught) {
error = caught instanceof Error ? caught.message : 'Unable to save settings.';
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>Settings | Journaley</title>
</svelte:head>
<div class="mx-auto max-w-4xl space-y-6">
<section>
<p class="text-sm font-semibold tracking-[0.2em] text-primary uppercase">Settings</p>
<h1 class="mt-2 text-3xl font-bold tracking-tight md:text-4xl">Tune your writing space</h1>
<p class="mt-2 text-muted-foreground">
Configure the basics for theme, defaults, and editor behavior.
</p>
</section>
<form
class="app-card space-y-6 p-5 md:p-6"
onsubmit={(event) => (event.preventDefault(), saveSettings())}
>
<div class="grid gap-5 md:grid-cols-2">
<label class="space-y-2">
<span class="text-sm font-semibold">Theme</span>
<select class="app-select" bind:value={theme}>
<option value="system">System</option>
<option value="light">Warm light</option>
<option value="dark">Dark</option>
</select>
</label>
<label class="space-y-2">
<span class="text-sm font-semibold">Default mood</span>
<select class="app-select capitalize" bind:value={defaultMood}>
{#each moods as mood (mood)}
<option value={mood}>{mood}</option>
{/each}
</select>
</label>
<label class="space-y-2">
<span class="text-sm font-semibold">Editor mode</span>
<select class="app-select" bind:value={editorMode}>
<option value="split">Split write and preview</option>
<option value="write">Write only</option>
<option value="preview">Preview only</option>
</select>
</label>
<label class="space-y-2">
<span class="text-sm font-semibold">Dashboard range</span>
<select class="app-select" bind:value={dashboardRange}>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
<option value="90d">90 days</option>
</select>
</label>
</div>
<div class="rounded-lg border bg-background/70 p-4">
<h2 class="font-bold">Local auth note</h2>
<p class="mt-1 text-sm leading-6 text-muted-foreground">
This setup uses Convex Auth email/password against your local Convex deployment. Production
auth settings can be added later when you are ready to deploy.
</p>
</div>
{#if error}
<p
class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</p>
{/if}
<div class="flex items-center justify-between border-t pt-4">
<p class="text-sm text-muted-foreground">
{saved ? 'Settings saved.' : 'Changes are saved to Convex.'}
</p>
<button class="app-button" type="submit" disabled={saving}
><Save size={16} /> {saving ? 'Saving...' : 'Save settings'}</button
>
</div>
</form>
</div>
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { useAuth } from '@mmailaender/convex-auth-svelte/sveltekit';
import Logo from '$lib/components/Logo.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
const auth = useAuth();
let mode = $state<'signIn' | 'signUp'>('signIn');
let email = $state('');
let password = $state('');
let error = $state('');
let pending = $state(false);
const redirectTo = $derived(page.url.searchParams.get('redirectTo') ?? '/dashboard');
async function submit() {
error = '';
pending = true;
try {
const result = await auth.signIn('password', {
email,
password,
flow: mode
});
if (result.redirect) {
window.location.href = result.redirect.toString();
return;
}
await goto(redirectTo);
} catch (caught) {
error = caught instanceof Error ? caught.message : 'Unable to authenticate.';
} finally {
pending = false;
}
}
</script>
<svelte:head>
<title>Sign in | Journaley</title>
</svelte:head>
<main class="grid min-h-screen place-items-center px-4 py-10">
<div class="absolute top-5 right-5">
<ThemeToggle />
</div>
<section class="app-card grid w-full max-w-5xl overflow-hidden md:grid-cols-[1fr_0.88fr]">
<div class="bg-primary p-8 text-primary-foreground md:p-10">
<div class="mb-10 inline-flex rounded-lg bg-white/15 p-3 text-white">
<Logo size={52} />
</div>
<p class="mb-3 text-sm font-semibold tracking-[0.22em] text-white/70 uppercase">Journaley</p>
<h1 class="max-w-md text-4xl font-bold tracking-tight md:text-5xl">
A calmer place for daily reflection.
</h1>
<p class="mt-5 max-w-md text-base leading-7 text-white/78">
Write in markdown, organize thoughts with tags, and search every reflection when you need
it.
</p>
</div>
<div class="p-8 md:p-10">
<h2 class="text-2xl font-bold tracking-tight">
{mode === 'signIn' ? 'Welcome back' : 'Create your journal'}
</h2>
<p class="mt-2 text-sm text-muted-foreground">
{mode === 'signIn'
? 'Sign in with your email and password.'
: 'Start locally with an email and password account.'}
</p>
<form class="mt-8 space-y-4" onsubmit={(event) => (event.preventDefault(), submit())}>
<label class="block space-y-2">
<span class="text-sm font-semibold">Email</span>
<input class="app-input" type="email" bind:value={email} autocomplete="email" required />
</label>
<label class="block space-y-2">
<span class="text-sm font-semibold">Password</span>
<input
class="app-input"
type="password"
bind:value={password}
autocomplete={mode === 'signIn' ? 'current-password' : 'new-password'}
minlength="8"
required
/>
</label>
{#if error}
<p
class="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</p>
{/if}
<button class="app-button w-full" type="submit" disabled={pending}>
{pending ? 'Working...' : mode === 'signIn' ? 'Sign in' : 'Create account'}
</button>
</form>
<button
class="mt-5 text-sm font-semibold text-primary"
type="button"
onclick={() => {
error = '';
mode = mode === 'signIn' ? 'signUp' : 'signIn';
}}
>
{mode === 'signIn' ? 'Need an account? Sign up' : 'Already have an account? Sign in'}
</button>
</div>
</section>
</main>