fixed issues with app

This commit is contained in:
2026-06-04 20:41:37 +02:00
parent eca8b6c815
commit 0fc72dbba6
10 changed files with 549 additions and 62 deletions
+21 -8
View File
@@ -1,5 +1,13 @@
import { query } from './_generated/server.js'; import { query } from './_generated/server.js';
import { requireOwner } from './lib.js'; import { requireOwner } from './lib.js';
import { dashboardRangeValidator } from './validators.js';
const rangeDays = {
'7d': 7,
'30d': 30,
'90d': 90,
'1y': 365
} as const;
function dayKey(date: Date) { function dayKey(date: Date) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
@@ -13,24 +21,27 @@ function daysAgo(days: number) {
} }
export const summary = query({ export const summary = query({
args: {}, args: { range: dashboardRangeValidator },
handler: async (ctx) => { handler: async (ctx, args) => {
const owner = await requireOwner(ctx); const owner = await requireOwner(ctx);
const selectedDays = rangeDays[args.range];
const entries = await ctx.db const entries = await ctx.db
.query('entries') .query('entries')
.withIndex('by_owner_and_entryDate', (q) => q.eq('owner', owner)) .withIndex('by_owner_and_entryDate', (q) => q.eq('owner', owner))
.order('desc') .order('desc')
.take(250); .take(Math.min(800, selectedDays * 3));
const activeEntries = entries.filter((entry) => !entry.archived); const activeEntries = entries.filter((entry) => !entry.archived);
const last30Start = daysAgo(29); const rangeStart = daysAgo(selectedDays - 1);
const last7Start = daysAgo(6); const last7Start = daysAgo(6);
const last30 = activeEntries.filter((entry) => entry.entryDate >= last30Start); const last30Start = daysAgo(29);
const rangedEntries = activeEntries.filter((entry) => entry.entryDate >= rangeStart);
const last7 = activeEntries.filter((entry) => entry.entryDate >= last7Start); const last7 = activeEntries.filter((entry) => entry.entryDate >= last7Start);
const last30 = activeEntries.filter((entry) => entry.entryDate >= last30Start);
const moodCounts: Record<string, number> = {}; const moodCounts: Record<string, number> = {};
const dailyCounts = new Map<string, number>(); const dailyCounts = new Map<string, number>();
const tagCounts: Record<string, number> = {}; const tagCounts: Record<string, number> = {};
for (const entry of last30) { for (const entry of rangedEntries) {
moodCounts[entry.mood] = (moodCounts[entry.mood] ?? 0) + 1; moodCounts[entry.mood] = (moodCounts[entry.mood] ?? 0) + 1;
dailyCounts.set(entry.entryDate, (dailyCounts.get(entry.entryDate) ?? 0) + 1); dailyCounts.set(entry.entryDate, (dailyCounts.get(entry.entryDate) ?? 0) + 1);
@@ -46,8 +57,8 @@ export const summary = query({
currentStreak += 1; currentStreak += 1;
} }
const dailySeries = Array.from({ length: 30 }, (_, index) => { const dailySeries = Array.from({ length: selectedDays }, (_, index) => {
const date = daysAgo(29 - index); const date = daysAgo(selectedDays - 1 - index);
return { date, count: dailyCounts.get(date) ?? 0 }; return { date, count: dailyCounts.get(date) ?? 0 };
}); });
@@ -55,6 +66,8 @@ export const summary = query({
totalEntries: activeEntries.length, totalEntries: activeEntries.length,
entriesThisWeek: last7.length, entriesThisWeek: last7.length,
entriesThisMonth: last30.length, entriesThisMonth: last30.length,
entriesInRange: rangedEntries.length,
range: args.range,
currentStreak, currentStreak,
averageWords: averageWords:
activeEntries.length === 0 activeEntries.length === 0
+99
View File
@@ -19,6 +19,46 @@ const sortValidator = v.union(
v.literal('title') v.literal('title')
); );
const markdownGuideBody = `# Markdown basics
Welcome to Journaley. This starter entry shows the markdown features you can use while journaling.
## Formatting
Use **bold** for emphasis, _italic_ for gentle notes, and \`inline code\` for short snippets.
## Lists
- Capture loose thoughts
- Group ideas by theme
- Keep reflections scannable
1. Start with what happened
2. Add how it felt
3. Close with one next step
## Tasks
- [ ] Try writing one short entry
- [ ] Add a few tags
- [ ] Search for this guide later
## Quotes
> A small daily note is still a thread you can return to.
## Links and tables
[Markdown guide](https://www.markdownguide.org/basic-syntax/)
| Syntax | Result |
| --- | --- |
| **bold** | bold text |
| _italic_ | italic text |
| # Heading | section title |
You can edit or delete this entry whenever you want.`;
type EntryWithTags = Doc<'entries'> & { type EntryWithTags = Doc<'entries'> & {
tags: Doc<'tags'>[]; tags: Doc<'tags'>[];
}; };
@@ -258,6 +298,65 @@ export const create = mutation({
} }
}); });
export const ensureMarkdownGuideEntry = mutation({
args: {},
handler: async (ctx) => {
const owner = await requireOwner(ctx);
const existingState = await ctx.db
.query('userState')
.withIndex('by_owner', (q) => q.eq('owner', owner))
.unique();
if (existingState?.markdownGuideSeeded) {
return existingState.markdownGuideEntryId ?? null;
}
const tags = await ensureTags(ctx, owner, ['markdown', 'guide']);
const now = Date.now();
const today = new Date().toISOString().slice(0, 10);
const plainText = markdownToPlainText(markdownGuideBody);
const entryId = await ctx.db.insert('entries', {
owner,
title: 'Markdown basics',
body: markdownGuideBody,
plainText,
searchText: buildSearchText({
title: 'Markdown basics',
body: markdownGuideBody,
mood: 'neutral',
tagNames: tags.map((tag) => tag.name)
}),
mood: 'neutral',
entryDate: today,
tagNames: tags.map((tag) => tag.name),
tagSlugs: tags.map((tag) => tag.slug),
pinned: true,
archived: false,
createdAt: now,
updatedAt: now
});
await applyEntryTags(ctx, owner, entryId, tags);
if (existingState) {
await ctx.db.patch(existingState._id, {
markdownGuideSeeded: true,
markdownGuideEntryId: entryId,
updatedAt: now
});
} else {
await ctx.db.insert('userState', {
owner,
markdownGuideSeeded: true,
markdownGuideEntryId: entryId,
updatedAt: now
});
}
return entryId;
}
});
export const update = mutation({ export const update = mutation({
args: { args: {
entryId: v.id('entries'), entryId: v.id('entries'),
+6
View File
@@ -61,5 +61,11 @@ export default defineSchema({
editorMode: editorModeValidator, editorMode: editorModeValidator,
dashboardRange: dashboardRangeValidator, dashboardRange: dashboardRangeValidator,
updatedAt: v.number() updatedAt: v.number()
}).index('by_owner', ['owner']),
userState: defineTable({
owner: v.string(),
markdownGuideSeeded: v.boolean(),
markdownGuideEntryId: v.optional(v.id('entries')),
updatedAt: v.number()
}).index('by_owner', ['owner']) }).index('by_owner', ['owner'])
}); });
+6 -1
View File
@@ -23,4 +23,9 @@ export const editorModeValidator = v.union(
v.literal('preview') v.literal('preview')
); );
export const dashboardRangeValidator = v.union(v.literal('7d'), v.literal('30d'), v.literal('90d')); export const dashboardRangeValidator = v.union(
v.literal('7d'),
v.literal('30d'),
v.literal('90d'),
v.literal('1y')
);
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import type { Doc } from '$convex/_generated/dataModel.js';
import { Archive, Edit3, Star, Trash2, X } from '@lucide/svelte';
import MarkdownPreview from '$lib/components/MarkdownPreview.svelte';
import { formatEntryDate } from '$lib/utils.js';
type EntryWithTags = Doc<'entries'> & {
tags: Doc<'tags'>[];
};
let {
entry,
onClose,
onEdit,
onDelete
}: {
entry: EntryWithTags;
onClose: () => void;
onEdit?: (entry: EntryWithTags) => void;
onDelete?: (entry: EntryWithTags) => void | Promise<void>;
} = $props();
</script>
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/48 p-4 backdrop-blur-sm">
<article class="mx-auto my-6 w-full max-w-4xl rounded-lg border bg-card shadow-2xl">
<header class="border-b p-4 md:p-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="mb-3 flex flex-wrap items-center gap-2">
<span class="rounded-sm bg-secondary px-2 py-1 text-xs font-bold capitalize">
{entry.mood}
</span>
{#if entry.pinned}
<span
class="inline-flex items-center gap-1 rounded-sm bg-accent px-2 py-1 text-xs font-bold text-accent-foreground"
>
<Star size={13} />
Pinned
</span>
{/if}
{#if entry.archived}
<span
class="inline-flex items-center gap-1 rounded-sm bg-muted px-2 py-1 text-xs font-bold text-muted-foreground"
>
<Archive size={13} />
Archived
</span>
{/if}
</div>
<h2 class="text-2xl font-bold tracking-tight md:text-3xl">{entry.title}</h2>
<p class="mt-2 text-sm text-muted-foreground">
{formatEntryDate(entry.entryDate)} · Updated {formatEntryDate(entry.updatedAt, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
})}
</p>
</div>
<button
class="app-button ghost px-2"
type="button"
onclick={onClose}
aria-label="Close entry"
>
<X size={18} />
</button>
</div>
{#if entry.tags.length}
<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>
{/if}
</header>
<div class="p-4 md:p-6">
<MarkdownPreview markdown={entry.body} />
</div>
{#if onEdit || onDelete}
<footer class="flex flex-wrap justify-end gap-3 border-t p-4">
{#if onEdit}
<button class="app-button secondary" type="button" onclick={() => onEdit?.(entry)}>
<Edit3 size={16} />
Edit
</button>
{/if}
{#if onDelete}
<button class="app-button destructive" type="button" onclick={() => onDelete?.(entry)}>
<Trash2 size={16} />
Delete
</button>
{/if}
</footer>
{/if}
</article>
</div>
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import {
Bold,
Code,
Heading2,
Italic,
Link,
List,
ListChecks,
ListOrdered,
Minus,
Quote,
Table2
} from '@lucide/svelte';
type MarkdownAction =
| 'heading'
| 'bold'
| 'italic'
| 'unordered-list'
| 'ordered-list'
| 'checklist'
| 'quote'
| 'code'
| 'link'
| 'table'
| 'divider';
let { onAction }: { onAction: (action: MarkdownAction) => void } = $props();
const actions: { action: MarkdownAction; label: string; icon: typeof Bold }[] = [
{ action: 'heading', label: 'Heading', icon: Heading2 },
{ action: 'bold', label: 'Bold', icon: Bold },
{ action: 'italic', label: 'Italic', icon: Italic },
{ action: 'unordered-list', label: 'Bullets', icon: List },
{ action: 'ordered-list', label: 'Numbered list', icon: ListOrdered },
{ action: 'checklist', label: 'Checklist', icon: ListChecks },
{ action: 'quote', label: 'Quote', icon: Quote },
{ action: 'code', label: 'Code block', icon: Code },
{ action: 'link', label: 'Link', icon: Link },
{ action: 'table', label: 'Table', icon: Table2 },
{ action: 'divider', label: 'Divider', icon: Minus }
];
</script>
<div class="flex flex-wrap gap-2 rounded-lg border bg-background/70 p-2">
{#each actions as item (item.action)}
{@const Icon = item.icon}
<button
class="app-button secondary px-2.5 py-2"
type="button"
title={item.label}
onclick={() => onAction(item.action)}
>
<Icon size={15} />
<span class="sr-only">{item.label}</span>
</button>
{/each}
</div>
+82 -22
View File
@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { BookOpenText, LayoutDashboard, LogOut, PenLine, Settings } from '@lucide/svelte'; import { api } from '$convex/_generated/api.js';
import { onMount } from 'svelte';
import { BookOpenText, LayoutDashboard, LogOut, Menu, PenLine, Settings } from '@lucide/svelte';
import { setupConvexAuth, useAuth } from '@mmailaender/convex-auth-svelte/sveltekit'; import { setupConvexAuth, useAuth } from '@mmailaender/convex-auth-svelte/sveltekit';
import { useConvexClient } from 'convex-svelte';
import Logo from '$lib/components/Logo.svelte'; import Logo from '$lib/components/Logo.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import './layout.css'; import './layout.css';
@@ -17,15 +20,36 @@
}); });
const auth = useAuth(); const auth = useAuth();
const client = useConvexClient();
const isAuthenticated = $derived(auth.isAuthenticated); const isAuthenticated = $derived(auth.isAuthenticated);
const isLoading = $derived(auth.isLoading); const isLoading = $derived(auth.isLoading);
const isAuthRoute = $derived(page.url.pathname.startsWith('/signin')); const isAuthRoute = $derived(page.url.pathname.startsWith('/signin'));
let navCollapsed = $state(false);
let guideSeedRequested = $state(false);
const navItems = [ const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/entries', label: 'Daily Entries', icon: BookOpenText }, { href: '/entries', label: 'Daily Entries', icon: BookOpenText },
{ href: '/settings', label: 'Settings', icon: Settings } { href: '/settings', label: 'Settings', icon: Settings }
]; ];
onMount(() => {
navCollapsed = localStorage.getItem('journaley-nav-collapsed') === 'true';
});
function toggleNav() {
navCollapsed = !navCollapsed;
localStorage.setItem('journaley-nav-collapsed', String(navCollapsed));
}
$effect(() => {
if (isLoading || !isAuthenticated || guideSeedRequested) return;
guideSeedRequested = true;
void client.mutation(api.entries.ensureMarkdownGuideEntry, {}).catch((error) => {
console.error('Failed to create markdown guide entry', error);
});
});
</script> </script>
{#if isLoading && !isAuthRoute} {#if isLoading && !isAuthRoute}
@@ -36,33 +60,53 @@
</div> </div>
</div> </div>
{:else if isAuthenticated && !isAuthRoute} {:else if isAuthenticated && !isAuthRoute}
<div class="min-h-screen lg:grid lg:grid-cols-[17rem_1fr]"> <div class="min-h-screen">
<aside class="hidden border-r bg-card/78 px-5 py-6 backdrop-blur lg:block"> <aside
<a href="/dashboard" class="mb-9 flex items-center gap-3"> class="fixed inset-y-0 left-0 z-30 flex flex-col border-r bg-card/90 px-3 py-4 backdrop-blur transition-[width] duration-200 {navCollapsed
<span class="text-primary"><Logo /></span> ? 'w-[4.75rem]'
<span> : 'w-72'}"
>
<div class="mb-7 flex items-center justify-between gap-2">
<a href="/dashboard" class="flex min-w-0 items-center gap-3">
<span class="shrink-0 text-primary"><Logo /></span>
{#if !navCollapsed}
<span class="min-w-0">
<span class="block text-lg font-bold tracking-tight">Journaley</span> <span class="block text-lg font-bold tracking-tight">Journaley</span>
<span class="text-xs text-muted-foreground">Write, tag, find.</span> <span class="text-xs whitespace-nowrap text-muted-foreground">Write, tag, find.</span>
</span> </span>
{/if}
</a> </a>
<button
class="app-button ghost shrink-0 px-2"
type="button"
onclick={toggleNav}
aria-label={navCollapsed ? 'Expand navigation' : 'Collapse navigation'}
>
<Menu size={18} />
</button>
</div>
<nav class="space-y-2"> <nav class="space-y-2">
{#each navItems as item (item.href)} {#each navItems as item (item.href)}
{@const Icon = item.icon} {@const Icon = item.icon}
<a <a
href={item.href} 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( class="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-semibold transition hover:bg-secondary {navCollapsed
item.href ? 'justify-center'
) : ''} {page.url.pathname.startsWith(item.href)
? 'bg-secondary text-foreground' ? 'bg-secondary text-foreground'
: 'text-muted-foreground'}" : 'text-muted-foreground'}"
title={navCollapsed ? item.label : undefined}
> >
<Icon size={18} /> <Icon class="shrink-0" size={18} />
{item.label} {#if !navCollapsed}
<span>{item.label}</span>
{/if}
</a> </a>
{/each} {/each}
</nav> </nav>
{#if !navCollapsed}
<div class="mt-8 rounded-lg border bg-background/70 p-4"> <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"> <div class="mb-2 flex items-center gap-2 text-sm font-semibold">
<PenLine size={16} /> <PenLine size={16} />
@@ -72,16 +116,36 @@
A few honest lines are enough. Keep the thread alive. A few honest lines are enough. Keep the thread alive.
</p> </p>
</div> </div>
{/if}
<div class="mt-auto space-y-2">
<div class={navCollapsed ? 'flex justify-center' : ''}>
<ThemeToggle />
</div>
<button
class="app-button ghost w-full {navCollapsed ? 'justify-center px-2' : ''}"
type="button"
onclick={() => auth.signOut()}
title={navCollapsed ? 'Sign out' : undefined}
>
<LogOut size={16} />
{#if !navCollapsed}
<span>Sign out</span>
{/if}
</button>
</div>
</aside> </aside>
<div class="min-w-0"> <div
class="min-w-0 transition-[padding] duration-200 {navCollapsed ? 'pl-[4.75rem]' : 'pl-72'}"
>
<header <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" 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"> <button class="app-button secondary px-3" type="button" onclick={toggleNav}>
<span class="text-primary"><Logo size={30} /></span> <Menu size={16} />
Journaley <span class="hidden sm:inline">{navCollapsed ? 'Expand' : 'Collapse'} menu</span>
</a> </button>
<nav class="hidden items-center gap-1 lg:flex"> <nav class="hidden items-center gap-1 lg:flex">
{#each navItems as item (item.href)} {#each navItems as item (item.href)}
@@ -95,11 +159,7 @@
</nav> </nav>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ThemeToggle /> <span class="hidden text-sm text-muted-foreground md:inline">Your private journal</span>
<button class="app-button ghost" type="button" onclick={() => auth.signOut()}>
<LogOut size={16} />
<span class="hidden sm:inline">Sign out</span>
</button>
</div> </div>
</header> </header>
+37 -8
View File
@@ -1,11 +1,25 @@
<script lang="ts"> <script lang="ts">
import { api } from '$convex/_generated/api.js'; import { api } from '$convex/_generated/api.js';
import type { Doc } from '$convex/_generated/dataModel.js';
import { BookOpenText, CalendarDays, Flame, Hash, Sparkles } from '@lucide/svelte'; import { BookOpenText, CalendarDays, Flame, Hash, Sparkles } from '@lucide/svelte';
import { useQuery } from 'convex-svelte'; import { useQuery } from 'convex-svelte';
import EntryViewModal from '$lib/components/EntryViewModal.svelte';
import { formatEntryDate } from '$lib/utils.js'; import { formatEntryDate } from '$lib/utils.js';
const summaryQuery = useQuery(api.analytics.summary, {}); type EntryWithTags = Doc<'entries'> & { tags: Doc<'tags'>[] };
type DashboardRange = '7d' | '30d' | '90d' | '1y';
const rangeLabels: Record<DashboardRange, string> = {
'7d': '7 days',
'30d': '30 days',
'90d': '90 days',
'1y': '1 year'
};
let selectedRange = $state<DashboardRange>('30d');
const summaryQuery = useQuery(api.analytics.summary, () => ({ range: selectedRange }));
const recentQuery = useQuery(api.entries.recent, { limit: 6 }); const recentQuery = useQuery(api.entries.recent, { limit: 6 });
let viewEntry = $state<EntryWithTags | null>(null);
const summary = $derived(summaryQuery.data); const summary = $derived(summaryQuery.data);
const recentEntries = $derived(recentQuery.data ?? []); const recentEntries = $derived(recentQuery.data ?? []);
@@ -25,7 +39,17 @@
Recent reflections, trends, and gentle signals from your writing rhythm. Recent reflections, trends, and gentle signals from your writing rhythm.
</p> </p>
</div> </div>
<div class="flex flex-wrap items-center gap-3">
<label class="min-w-36 space-y-1 text-sm font-semibold">
<span class="sr-only">Activity range</span>
<select class="app-select" bind:value={selectedRange}>
{#each Object.entries(rangeLabels) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
</label>
<a class="app-button" href="/entries">New entry</a> <a class="app-button" href="/entries">New entry</a>
</div>
</section> </section>
{#if summaryQuery.isLoading} {#if summaryQuery.isLoading}
@@ -58,13 +82,13 @@
<div class="app-card p-5"> <div class="app-card p-5">
<div class="mb-5 flex items-center justify-between"> <div class="mb-5 flex items-center justify-between">
<div> <div>
<h2 class="text-lg font-bold">30-day activity</h2> <h2 class="text-lg font-bold">{rangeLabels[selectedRange]} activity</h2>
<p class="text-sm text-muted-foreground">{summary.insight}</p> <p class="text-sm text-muted-foreground">{summary.insight}</p>
</div> </div>
</div> </div>
<div class="flex h-48 items-end gap-1.5"> <div class="flex h-48 items-end gap-1 overflow-x-auto pb-2">
{#each summary.dailySeries as day (day.date)} {#each summary.dailySeries as day (day.date)}
<div class="flex flex-1 flex-col items-center gap-2"> <div class="flex min-w-1 flex-1 flex-col items-center gap-2">
<div <div
class="w-full rounded-sm bg-primary/80" class="w-full rounded-sm bg-primary/80"
style={`height: ${Math.max(8, (day.count / maxDaily) * 170)}px`} style={`height: ${Math.max(8, (day.count / maxDaily) * 170)}px`}
@@ -106,9 +130,10 @@
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each recentEntries as entry (entry._id)} {#each recentEntries as entry (entry._id)}
<a <button
class="rounded-lg border bg-background/60 p-4 transition hover:border-primary/50" class="rounded-lg border bg-background/60 p-4 text-left transition hover:border-primary/50"
href="/entries" type="button"
onclick={() => (viewEntry = entry)}
> >
<div class="mb-3 flex items-center justify-between gap-3"> <div class="mb-3 flex items-center justify-between gap-3">
<h3 class="line-clamp-1 font-bold">{entry.title}</h3> <h3 class="line-clamp-1 font-bold">{entry.title}</h3>
@@ -120,7 +145,7 @@
</div> </div>
<p class="text-xs text-muted-foreground">{formatEntryDate(entry.entryDate)}</p> <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> <p class="mt-3 line-clamp-3 text-sm text-muted-foreground">{entry.plainText}</p>
</a> </button>
{:else} {:else}
<div <div
class="rounded-lg border border-dashed p-6 text-sm text-muted-foreground md:col-span-2 xl:col-span-3" class="rounded-lg border border-dashed p-6 text-sm text-muted-foreground md:col-span-2 xl:col-span-3"
@@ -131,3 +156,7 @@
</div> </div>
</section> </section>
</div> </div>
{#if viewEntry}
<EntryViewModal entry={viewEntry} onClose={() => (viewEntry = null)} />
{/if}
+114 -3
View File
@@ -1,14 +1,29 @@
<script lang="ts"> <script lang="ts">
import { api } from '$convex/_generated/api.js'; import { api } from '$convex/_generated/api.js';
import type { Doc } from '$convex/_generated/dataModel.js'; import type { Doc } from '$convex/_generated/dataModel.js';
import { tick } from 'svelte';
import { Archive, Edit3, Plus, Search, Star, Trash2, X } from '@lucide/svelte'; import { Archive, Edit3, Plus, Search, Star, Trash2, X } from '@lucide/svelte';
import { useConvexClient, useQuery } from 'convex-svelte'; import { useConvexClient, useQuery } from 'convex-svelte';
import EntryViewModal from '$lib/components/EntryViewModal.svelte';
import MarkdownToolbar from '$lib/components/MarkdownToolbar.svelte';
import MarkdownPreview from '$lib/components/MarkdownPreview.svelte'; import MarkdownPreview from '$lib/components/MarkdownPreview.svelte';
import { formatEntryDate, todayISO } from '$lib/utils.js'; import { formatEntryDate, todayISO } from '$lib/utils.js';
type Mood = 'joyful' | 'calm' | 'neutral' | 'tired' | 'stressed' | 'sad' | 'angry' | 'grateful'; type Mood = 'joyful' | 'calm' | 'neutral' | 'tired' | 'stressed' | 'sad' | 'angry' | 'grateful';
type Sort = 'newest' | 'oldest' | 'updated' | 'title'; type Sort = 'newest' | 'oldest' | 'updated' | 'title';
type EntryWithTags = Doc<'entries'> & { tags: Doc<'tags'>[] }; type EntryWithTags = Doc<'entries'> & { tags: Doc<'tags'>[] };
type MarkdownAction =
| 'heading'
| 'bold'
| 'italic'
| 'unordered-list'
| 'ordered-list'
| 'checklist'
| 'quote'
| 'code'
| 'link'
| 'table'
| 'divider';
const moods: Mood[] = [ const moods: Mood[] = [
'joyful', 'joyful',
@@ -32,6 +47,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
let dateTo = $state(''); let dateTo = $state('');
let includeArchived = $state(false); let includeArchived = $state(false);
let sort = $state<Sort>('newest'); let sort = $state<Sort>('newest');
let viewEntry = $state<EntryWithTags | null>(null);
let editorOpen = $state(false); let editorOpen = $state(false);
let previewMode = $state<'write' | 'preview' | 'split'>('split'); let previewMode = $state<'write' | 'preview' | 'split'>('split');
let editingEntry = $state<EntryWithTags | null>(null); let editingEntry = $state<EntryWithTags | null>(null);
@@ -44,6 +60,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
let archived = $state(false); let archived = $state(false);
let saving = $state(false); let saving = $state(false);
let formError = $state(''); let formError = $state('');
let bodyTextarea: HTMLTextAreaElement | undefined = $state();
const entriesQuery = useQuery( const entriesQuery = useQuery(
api.entries.list, api.entries.list,
@@ -80,9 +97,14 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
function openCreate() { function openCreate() {
resetForm(); resetForm();
viewEntry = null;
editorOpen = true; editorOpen = true;
} }
function openView(entry: EntryWithTags) {
viewEntry = entry;
}
function openEdit(entry: EntryWithTags) { function openEdit(entry: EntryWithTags) {
editingEntry = entry; editingEntry = entry;
title = entry.title; title = entry.title;
@@ -94,6 +116,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
archived = entry.archived; archived = entry.archived;
previewMode = (settingsQuery.data?.editorMode ?? 'split') as typeof previewMode; previewMode = (settingsQuery.data?.editorMode ?? 'split') as typeof previewMode;
formError = ''; formError = '';
viewEntry = null;
editorOpen = true; editorOpen = true;
} }
@@ -142,6 +165,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
async function removeEntry(entry: EntryWithTags) { async function removeEntry(entry: EntryWithTags) {
if (!confirm(`Delete "${entry.title}"? This cannot be undone.`)) return; if (!confirm(`Delete "${entry.title}"? This cannot be undone.`)) return;
await client.mutation(api.entries.remove, { entryId: entry._id }); await client.mutation(api.entries.remove, { entryId: entry._id });
if (viewEntry?._id === entry._id) viewEntry = null;
} }
function toggleTag(slug: string) { function toggleTag(slug: string) {
@@ -149,6 +173,73 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
? selectedTagSlugs.filter((value) => value !== slug) ? selectedTagSlugs.filter((value) => value !== slug)
: [...selectedTagSlugs, slug]; : [...selectedTagSlugs, slug];
} }
function markdownSnippet(action: MarkdownAction, selected: string) {
const text = selected || 'text';
switch (action) {
case 'heading':
return `## ${selected || 'Section heading'}`;
case 'bold':
return `**${text}**`;
case 'italic':
return `_${text}_`;
case 'unordered-list':
return selected
? selected
.split('\n')
.map((line) => `- ${line}`)
.join('\n')
: '- First thought\n- Second thought';
case 'ordered-list':
return selected
? selected
.split('\n')
.map((line, index) => `${index + 1}. ${line}`)
.join('\n')
: '1. First step\n2. Second step';
case 'checklist':
return selected
? selected
.split('\n')
.map((line) => `- [ ] ${line}`)
.join('\n')
: '- [ ] Something to remember';
case 'quote':
return selected
? selected
.split('\n')
.map((line) => `> ${line}`)
.join('\n')
: '> A thought worth keeping';
case 'code':
return `\`\`\`\n${selected || 'code or notes'}\n\`\`\``;
case 'link':
return `[${selected || 'link text'}](https://example.com)`;
case 'table':
return '| Topic | Notes |\n| --- | --- |\n| Today | Reflection |';
case 'divider':
return '---';
}
}
async function applyMarkdownAction(action: MarkdownAction) {
const start = bodyTextarea?.selectionStart ?? body.length;
const end = bodyTextarea?.selectionEnd ?? body.length;
const selected = body.slice(start, end);
const snippet = markdownSnippet(action, selected);
const prefix = start > 0 && !body.slice(0, start).endsWith('\n') ? '\n' : '';
const suffix = end < body.length && !body.slice(end).startsWith('\n') ? '\n' : '';
body = `${body.slice(0, start)}${prefix}${snippet}${suffix}${body.slice(end)}`;
await tick();
if (bodyTextarea) {
const cursor = start + prefix.length + snippet.length;
bodyTextarea.focus();
bodyTextarea.setSelectionRange(cursor, cursor);
}
}
</script> </script>
<svelte:head> <svelte:head>
@@ -238,9 +329,9 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
<div class="app-card p-6 text-sm text-muted-foreground">Loading entries...</div> <div class="app-card p-6 text-sm text-muted-foreground">Loading entries...</div>
{:else} {:else}
{#each entries as entry (entry._id)} {#each entries as entry (entry._id)}
<article class="app-card p-5"> <article class="app-card p-5 transition hover:border-primary/50">
<div class="mb-4 flex items-start justify-between gap-4"> <div class="mb-4 flex items-start justify-between gap-4">
<div> <button class="min-w-0 flex-1 text-left" type="button" onclick={() => openView(entry)}>
<div class="mb-2 flex flex-wrap items-center gap-2"> <div class="mb-2 flex flex-wrap items-center gap-2">
{#if entry.pinned} {#if entry.pinned}
<span <span
@@ -262,7 +353,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
</div> </div>
<h2 class="text-xl font-bold tracking-tight">{entry.title}</h2> <h2 class="text-xl font-bold tracking-tight">{entry.title}</h2>
<p class="mt-1 text-sm text-muted-foreground">{formatEntryDate(entry.entryDate)}</p> <p class="mt-1 text-sm text-muted-foreground">{formatEntryDate(entry.entryDate)}</p>
</div> </button>
<div class="flex gap-1"> <div class="flex gap-1">
<button <button
@@ -284,6 +375,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
</div> </div>
</div> </div>
<button class="block w-full text-left" type="button" onclick={() => openView(entry)}>
<p class="line-clamp-4 text-sm leading-6 text-muted-foreground">{entry.plainText}</p> <p class="line-clamp-4 text-sm leading-6 text-muted-foreground">{entry.plainText}</p>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
@@ -293,6 +385,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
> >
{/each} {/each}
</div> </div>
</button>
</article> </article>
{:else} {:else}
<div class="app-card border-dashed p-8 text-center xl:col-span-2"> <div class="app-card border-dashed p-8 text-center xl:col-span-2">
@@ -307,6 +400,15 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
</section> </section>
</div> </div>
{#if viewEntry}
<EntryViewModal
entry={viewEntry}
onClose={() => (viewEntry = null)}
onEdit={openEdit}
onDelete={removeEntry}
/>
{/if}
{#if editorOpen} {#if editorOpen}
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/48 p-4 backdrop-blur-sm"> <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="mx-auto my-6 w-full max-w-6xl rounded-lg border bg-card shadow-2xl">
@@ -366,6 +468,14 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
> >
</label> </label>
<div class="space-y-2">
<span class="text-sm font-semibold">Writing toolbar</span>
<MarkdownToolbar onAction={applyMarkdownAction} />
<p class="text-xs text-muted-foreground">
Use these buttons to add markdown formatting. The preview updates immediately.
</p>
</div>
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex rounded-md border bg-background p-1"> <div class="flex rounded-md border bg-background p-1">
{#each ['write', 'preview', 'split'] as mode (mode)} {#each ['write', 'preview', 'split'] as mode (mode)}
@@ -397,6 +507,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
<label class="space-y-2"> <label class="space-y-2">
<span class="text-sm font-semibold">Markdown</span> <span class="text-sm font-semibold">Markdown</span>
<textarea <textarea
bind:this={bodyTextarea}
class="app-textarea min-h-[28rem]" class="app-textarea min-h-[28rem]"
bind:value={body} bind:value={body}
placeholder={markdownPlaceholder} placeholder={markdownPlaceholder}
+2 -1
View File
@@ -6,7 +6,7 @@
type Theme = 'system' | 'light' | 'dark'; type Theme = 'system' | 'light' | 'dark';
type Mood = 'joyful' | 'calm' | 'neutral' | 'tired' | 'stressed' | 'sad' | 'angry' | 'grateful'; type Mood = 'joyful' | 'calm' | 'neutral' | 'tired' | 'stressed' | 'sad' | 'angry' | 'grateful';
type EditorMode = 'split' | 'write' | 'preview'; type EditorMode = 'split' | 'write' | 'preview';
type DashboardRange = '7d' | '30d' | '90d'; type DashboardRange = '7d' | '30d' | '90d' | '1y';
const moods: Mood[] = [ const moods: Mood[] = [
'joyful', 'joyful',
@@ -134,6 +134,7 @@
<option value="7d">7 days</option> <option value="7d">7 days</option>
<option value="30d">30 days</option> <option value="30d">30 days</option>
<option value="90d">90 days</option> <option value="90d">90 days</option>
<option value="1y">1 year</option>
</select> </select>
</label> </label>
</div> </div>