fixed issues with app
This commit is contained in:
+21
-8
@@ -1,5 +1,13 @@
|
||||
import { query } from './_generated/server.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) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
@@ -13,24 +21,27 @@ function daysAgo(days: number) {
|
||||
}
|
||||
|
||||
export const summary = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
args: { range: dashboardRangeValidator },
|
||||
handler: async (ctx, args) => {
|
||||
const owner = await requireOwner(ctx);
|
||||
const selectedDays = rangeDays[args.range];
|
||||
const entries = await ctx.db
|
||||
.query('entries')
|
||||
.withIndex('by_owner_and_entryDate', (q) => q.eq('owner', owner))
|
||||
.order('desc')
|
||||
.take(250);
|
||||
.take(Math.min(800, selectedDays * 3));
|
||||
const activeEntries = entries.filter((entry) => !entry.archived);
|
||||
const last30Start = daysAgo(29);
|
||||
const rangeStart = daysAgo(selectedDays - 1);
|
||||
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 last30 = activeEntries.filter((entry) => entry.entryDate >= last30Start);
|
||||
const moodCounts: Record<string, number> = {};
|
||||
const dailyCounts = new Map<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;
|
||||
dailyCounts.set(entry.entryDate, (dailyCounts.get(entry.entryDate) ?? 0) + 1);
|
||||
|
||||
@@ -46,8 +57,8 @@ export const summary = query({
|
||||
currentStreak += 1;
|
||||
}
|
||||
|
||||
const dailySeries = Array.from({ length: 30 }, (_, index) => {
|
||||
const date = daysAgo(29 - index);
|
||||
const dailySeries = Array.from({ length: selectedDays }, (_, index) => {
|
||||
const date = daysAgo(selectedDays - 1 - index);
|
||||
return { date, count: dailyCounts.get(date) ?? 0 };
|
||||
});
|
||||
|
||||
@@ -55,6 +66,8 @@ export const summary = query({
|
||||
totalEntries: activeEntries.length,
|
||||
entriesThisWeek: last7.length,
|
||||
entriesThisMonth: last30.length,
|
||||
entriesInRange: rangedEntries.length,
|
||||
range: args.range,
|
||||
currentStreak,
|
||||
averageWords:
|
||||
activeEntries.length === 0
|
||||
|
||||
@@ -19,6 +19,46 @@ const sortValidator = v.union(
|
||||
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'> & {
|
||||
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({
|
||||
args: {
|
||||
entryId: v.id('entries'),
|
||||
|
||||
@@ -61,5 +61,11 @@ export default defineSchema({
|
||||
editorMode: editorModeValidator,
|
||||
dashboardRange: dashboardRangeValidator,
|
||||
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'])
|
||||
});
|
||||
|
||||
@@ -23,4 +23,9 @@ export const editorModeValidator = v.union(
|
||||
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')
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+92
-32
@@ -1,8 +1,11 @@
|
||||
<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 { 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 { useConvexClient } from 'convex-svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import './layout.css';
|
||||
@@ -17,15 +20,36 @@
|
||||
});
|
||||
|
||||
const auth = useAuth();
|
||||
const client = useConvexClient();
|
||||
const isAuthenticated = $derived(auth.isAuthenticated);
|
||||
const isLoading = $derived(auth.isLoading);
|
||||
const isAuthRoute = $derived(page.url.pathname.startsWith('/signin'));
|
||||
let navCollapsed = $state(false);
|
||||
let guideSeedRequested = $state(false);
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/entries', label: 'Daily Entries', icon: BookOpenText },
|
||||
{ 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>
|
||||
|
||||
{#if isLoading && !isAuthRoute}
|
||||
@@ -36,52 +60,92 @@
|
||||
</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>
|
||||
<div class="min-h-screen">
|
||||
<aside
|
||||
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
|
||||
? 'w-[4.75rem]'
|
||||
: '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="text-xs whitespace-nowrap text-muted-foreground">Write, tag, find.</span>
|
||||
</span>
|
||||
{/if}
|
||||
</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">
|
||||
{#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
|
||||
)
|
||||
class="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-semibold transition hover:bg-secondary {navCollapsed
|
||||
? 'justify-center'
|
||||
: ''} {page.url.pathname.startsWith(item.href)
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
title={navCollapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
<Icon class="shrink-0" size={18} />
|
||||
{#if !navCollapsed}
|
||||
<span>{item.label}</span>
|
||||
{/if}
|
||||
</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
|
||||
{#if !navCollapsed}
|
||||
<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>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
A few honest lines are enough. Keep the thread alive.
|
||||
</p>
|
||||
{/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>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="min-w-0 transition-[padding] duration-200 {navCollapsed ? 'pl-[4.75rem]' : 'pl-72'}"
|
||||
>
|
||||
<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>
|
||||
<button class="app-button secondary px-3" type="button" onclick={toggleNav}>
|
||||
<Menu size={16} />
|
||||
<span class="hidden sm:inline">{navCollapsed ? 'Expand' : 'Collapse'} menu</span>
|
||||
</button>
|
||||
|
||||
<nav class="hidden items-center gap-1 lg:flex">
|
||||
{#each navItems as item (item.href)}
|
||||
@@ -95,11 +159,7 @@
|
||||
</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>
|
||||
<span class="hidden text-sm text-muted-foreground md:inline">Your private journal</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
<script lang="ts">
|
||||
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 { useQuery } from 'convex-svelte';
|
||||
import EntryViewModal from '$lib/components/EntryViewModal.svelte';
|
||||
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 });
|
||||
let viewEntry = $state<EntryWithTags | null>(null);
|
||||
|
||||
const summary = $derived(summaryQuery.data);
|
||||
const recentEntries = $derived(recentQuery.data ?? []);
|
||||
@@ -25,7 +39,17 @@
|
||||
Recent reflections, trends, and gentle signals from your writing rhythm.
|
||||
</p>
|
||||
</div>
|
||||
<a class="app-button" href="/entries">New entry</a>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if summaryQuery.isLoading}
|
||||
@@ -58,13 +82,13 @@
|
||||
<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>
|
||||
<h2 class="text-lg font-bold">{rangeLabels[selectedRange]} activity</h2>
|
||||
<p class="text-sm text-muted-foreground">{summary.insight}</p>
|
||||
</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)}
|
||||
<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
|
||||
class="w-full rounded-sm bg-primary/80"
|
||||
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">
|
||||
{#each recentEntries as entry (entry._id)}
|
||||
<a
|
||||
class="rounded-lg border bg-background/60 p-4 transition hover:border-primary/50"
|
||||
href="/entries"
|
||||
<button
|
||||
class="rounded-lg border bg-background/60 p-4 text-left transition hover:border-primary/50"
|
||||
type="button"
|
||||
onclick={() => (viewEntry = entry)}
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 class="line-clamp-1 font-bold">{entry.title}</h3>
|
||||
@@ -120,7 +145,7 @@
|
||||
</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>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if viewEntry}
|
||||
<EntryViewModal entry={viewEntry} onClose={() => (viewEntry = null)} />
|
||||
{/if}
|
||||
|
||||
+122
-11
@@ -1,14 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$convex/_generated/api.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 { 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 { 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'>[] };
|
||||
type MarkdownAction =
|
||||
| 'heading'
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'unordered-list'
|
||||
| 'ordered-list'
|
||||
| 'checklist'
|
||||
| 'quote'
|
||||
| 'code'
|
||||
| 'link'
|
||||
| 'table'
|
||||
| 'divider';
|
||||
|
||||
const moods: Mood[] = [
|
||||
'joyful',
|
||||
@@ -32,6 +47,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
|
||||
let dateTo = $state('');
|
||||
let includeArchived = $state(false);
|
||||
let sort = $state<Sort>('newest');
|
||||
let viewEntry = $state<EntryWithTags | null>(null);
|
||||
let editorOpen = $state(false);
|
||||
let previewMode = $state<'write' | 'preview' | 'split'>('split');
|
||||
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 saving = $state(false);
|
||||
let formError = $state('');
|
||||
let bodyTextarea: HTMLTextAreaElement | undefined = $state();
|
||||
|
||||
const entriesQuery = useQuery(
|
||||
api.entries.list,
|
||||
@@ -80,9 +97,14 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
|
||||
|
||||
function openCreate() {
|
||||
resetForm();
|
||||
viewEntry = null;
|
||||
editorOpen = true;
|
||||
}
|
||||
|
||||
function openView(entry: EntryWithTags) {
|
||||
viewEntry = entry;
|
||||
}
|
||||
|
||||
function openEdit(entry: EntryWithTags) {
|
||||
editingEntry = entry;
|
||||
title = entry.title;
|
||||
@@ -94,6 +116,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
|
||||
archived = entry.archived;
|
||||
previewMode = (settingsQuery.data?.editorMode ?? 'split') as typeof previewMode;
|
||||
formError = '';
|
||||
viewEntry = null;
|
||||
editorOpen = true;
|
||||
}
|
||||
|
||||
@@ -142,6 +165,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
|
||||
async function removeEntry(entry: EntryWithTags) {
|
||||
if (!confirm(`Delete "${entry.title}"? This cannot be undone.`)) return;
|
||||
await client.mutation(api.entries.remove, { entryId: entry._id });
|
||||
if (viewEntry?._id === entry._id) viewEntry = null;
|
||||
}
|
||||
|
||||
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, 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>
|
||||
|
||||
<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>
|
||||
{:else}
|
||||
{#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>
|
||||
<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">
|
||||
{#if entry.pinned}
|
||||
<span
|
||||
@@ -262,7 +353,7 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
|
||||
</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>
|
||||
</button>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
@@ -284,15 +375,17 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="line-clamp-4 text-sm leading-6 text-muted-foreground">{entry.plainText}</p>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
</article>
|
||||
{:else}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{#if viewEntry}
|
||||
<EntryViewModal
|
||||
entry={viewEntry}
|
||||
onClose={() => (viewEntry = null)}
|
||||
onEdit={openEdit}
|
||||
onDelete={removeEntry}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
@@ -366,6 +468,14 @@ Write with **markdown**, tags, lists, links, code blocks, tables, and more.`;
|
||||
>
|
||||
</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 rounded-md border bg-background p-1">
|
||||
{#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">
|
||||
<span class="text-sm font-semibold">Markdown</span>
|
||||
<textarea
|
||||
bind:this={bodyTextarea}
|
||||
class="app-textarea min-h-[28rem]"
|
||||
bind:value={body}
|
||||
placeholder={markdownPlaceholder}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
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';
|
||||
type DashboardRange = '7d' | '30d' | '90d' | '1y';
|
||||
|
||||
const moods: Mood[] = [
|
||||
'joyful',
|
||||
@@ -134,6 +134,7 @@
|
||||
<option value="7d">7 days</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user