full working application and initial commit
This commit is contained in:
Vendored
+13
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
// Reexport your entry components here
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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');
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<p>Loading Journaley...</p>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user