feat: Add notes with markdown editor #1
@@ -65,6 +65,8 @@
|
|||||||
"@triplit/client": "^1.0.50",
|
"@triplit/client": "^1.0.50",
|
||||||
"@triplit/svelte": "^1.0.50",
|
"@triplit/svelte": "^1.0.50",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"carta-md": "^4.11.1",
|
||||||
|
"isomorphic-dompurify": "^3.0.0",
|
||||||
"svelte-confetti": "^2.3.2",
|
"svelte-confetti": "^2.3.2",
|
||||||
"valibot": "^1.2.0"
|
"valibot": "^1.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1225
-6
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
|||||||
|
import { triplit, Query } from '../triplit/client';
|
||||||
|
import type { Note } from '../triplit/schema';
|
||||||
|
|
||||||
|
export type CreateNoteData = Omit<Note, 'id' | 'createdAt' | 'updatedAt' | 'pinned' | 'archived' | 'deleted' | 'deletedAt'> & {
|
||||||
|
pinned?: boolean;
|
||||||
|
archived?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateNoteData = Partial<Omit<Note, 'id' | 'userId' | 'createdAt'>>;
|
||||||
|
|
||||||
|
export async function createNote(data: CreateNoteData) {
|
||||||
|
return await triplit.insert('notes', {
|
||||||
|
userId: data.userId,
|
||||||
|
title: data.title || '',
|
||||||
|
content: data.content || '',
|
||||||
|
pinned: data.pinned ?? false,
|
||||||
|
archived: data.archived ?? false,
|
||||||
|
deleted: false,
|
||||||
|
tags: data.tags || new Set(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNote(id: string, data: UpdateNoteData) {
|
||||||
|
return await triplit.update('notes', id, {
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNote(id: string) {
|
||||||
|
return await triplit.update('notes', id, {
|
||||||
|
deleted: true,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreNote(id: string) {
|
||||||
|
return await triplit.update('notes', id, {
|
||||||
|
deleted: false,
|
||||||
|
deletedAt: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function permanentDelete(id: string) {
|
||||||
|
return await triplit.delete('notes', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveNote(id: string, archived: boolean = true) {
|
||||||
|
return await triplit.update('notes', id, {
|
||||||
|
archived,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pinNote(id: string, pinned: boolean = true) {
|
||||||
|
return await triplit.update('notes', id, {
|
||||||
|
pinned,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTag(id: string, tag: string) {
|
||||||
|
const note = await triplit.fetchById('notes', id);
|
||||||
|
if (!note) return null;
|
||||||
|
|
||||||
|
const tags = new Set(note.tags);
|
||||||
|
tags.add(tag);
|
||||||
|
|
||||||
|
return await triplit.update('notes', id, {
|
||||||
|
tags,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTag(id: string, tag: string) {
|
||||||
|
const note = await triplit.fetchById('notes', id);
|
||||||
|
if (!note) return null;
|
||||||
|
|
||||||
|
const tags = new Set(note.tags);
|
||||||
|
tags.delete(tag);
|
||||||
|
|
||||||
|
return await triplit.update('notes', id, {
|
||||||
|
tags,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotes(userId: string) {
|
||||||
|
return Query('notes')
|
||||||
|
.Where('userId', '=', userId)
|
||||||
|
.Where('deleted', '=', false)
|
||||||
|
.Order('updatedAt', 'DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getArchivedNotes(userId: string) {
|
||||||
|
return Query('notes')
|
||||||
|
.Where('userId', '=', userId)
|
||||||
|
.Where('archived', '=', true)
|
||||||
|
.Where('deleted', '=', false)
|
||||||
|
.Order('updatedAt', 'DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrashedNotes(userId: string) {
|
||||||
|
return Query('notes')
|
||||||
|
.Where('userId', '=', userId)
|
||||||
|
.Where('deleted', '=', true)
|
||||||
|
.Order('deletedAt', 'DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNote(id: string) {
|
||||||
|
return Query('notes')
|
||||||
|
.Where('id', '=', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emptyTrash(userId: string) {
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
const trashedNotes = await triplit.fetch(
|
||||||
|
Query('notes')
|
||||||
|
.Where('userId', '=', userId)
|
||||||
|
.Where('deleted', '=', true)
|
||||||
|
.Where('deletedAt', '<=', thirtyDaysAgo)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const note of trashedNotes) {
|
||||||
|
await triplit.delete('notes', note.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trashedNotes.length;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const badgeVariants = tv({
|
||||||
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||||
|
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
href,
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element
|
||||||
|
this={href ? "a" : "span"}
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="badge"
|
||||||
|
{href}
|
||||||
|
class={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</svelte:element>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as Badge } from "./badge.svelte";
|
||||||
|
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { getContext, onDestroy, setContext } from 'svelte';
|
||||||
|
import type { Note } from '../triplit/schema';
|
||||||
|
import type { CreateNoteData, UpdateNoteData } from '../api/notes.triplit';
|
||||||
|
import * as notesApi from '../api/notes.triplit';
|
||||||
|
import { triplit } from '../triplit/client';
|
||||||
|
|
||||||
|
export class NoteService {
|
||||||
|
notes = $state<Note[]>([]);
|
||||||
|
loading = $state(true);
|
||||||
|
error = $state<string | null>(null);
|
||||||
|
|
||||||
|
private notesUnsubscribe?: () => void;
|
||||||
|
|
||||||
|
// Derived state - computed values using $derived
|
||||||
|
activeNotes = $derived(
|
||||||
|
this.notes.filter(n => !n.deleted && !n.archived)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Pinned notes first, then by updatedAt
|
||||||
|
if (a.pinned && !b.pinned) return -1;
|
||||||
|
if (!a.pinned && b.pinned) return 1;
|
||||||
|
return b.updatedAt.getTime() - a.updatedAt.getTime();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
pinnedNotes = $derived(
|
||||||
|
this.activeNotes.filter(n => n.pinned)
|
||||||
|
);
|
||||||
|
|
||||||
|
archivedNotes = $derived(
|
||||||
|
this.notes.filter(n => n.archived && !n.deleted)
|
||||||
|
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
||||||
|
);
|
||||||
|
|
||||||
|
trashedNotes = $derived(
|
||||||
|
this.notes.filter(n => n.deleted)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aTime = a.deletedAt?.getTime() || 0;
|
||||||
|
const bTime = b.deletedAt?.getTime() || 0;
|
||||||
|
return bTime - aTime;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor(private userId: string) {
|
||||||
|
this.setupSubscription();
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
this.notesUnsubscribe?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSubscription() {
|
||||||
|
this.notesUnsubscribe = triplit.subscribe(
|
||||||
|
notesApi.getNotes(this.userId),
|
||||||
|
(results) => {
|
||||||
|
this.notes = Array.from(results);
|
||||||
|
this.loading = false;
|
||||||
|
this.error = null;
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
console.error('Error in notes subscription:', err);
|
||||||
|
this.error = 'Failed to load notes';
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNote(data: Omit<CreateNoteData, 'userId'>) {
|
||||||
|
try {
|
||||||
|
return await notesApi.createNote({ ...data, userId: this.userId });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create note:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateNote(id: string, data: UpdateNoteData) {
|
||||||
|
try {
|
||||||
|
return await notesApi.updateNote(id, data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update note:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteNote(id: string) {
|
||||||
|
try {
|
||||||
|
return await notesApi.deleteNote(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete note:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreNote(id: string) {
|
||||||
|
try {
|
||||||
|
return await notesApi.restoreNote(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to restore note:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async permanentDelete(id: string) {
|
||||||
|
try {
|
||||||
|
return await notesApi.permanentDelete(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to permanently delete note:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async archiveNote(id: string, archived: boolean = true) {
|
||||||
|
try {
|
||||||
|
return await notesApi.archiveNote(id, archived);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to archive note:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pinNote(id: string, pinned: boolean = true) {
|
||||||
|
try {
|
||||||
|
return await notesApi.pinNote(id, pinned);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to pin note:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTag(id: string, tag: string) {
|
||||||
|
try {
|
||||||
|
return await notesApi.addTag(id, tag);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add tag:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeTag(id: string, tag: string) {
|
||||||
|
try {
|
||||||
|
return await notesApi.removeTag(id, tag);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to remove tag:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async emptyTrash() {
|
||||||
|
try {
|
||||||
|
return await notesApi.emptyTrash(this.userId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to empty trash:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchNotes(query: string, notesArray: Note[] = this.activeNotes): Note[] {
|
||||||
|
if (!query.trim()) return notesArray;
|
||||||
|
|
||||||
|
const searchTerm = query.toLowerCase();
|
||||||
|
|
||||||
|
return notesArray.filter(note => {
|
||||||
|
// Search in title and content
|
||||||
|
const titleMatch = note.title.toLowerCase().includes(searchTerm);
|
||||||
|
const contentMatch = note.content.toLowerCase().includes(searchTerm);
|
||||||
|
|
||||||
|
// Search in tags
|
||||||
|
const tagMatch = Array.from(note.tags).some(tag =>
|
||||||
|
tag.toLowerCase().includes(searchTerm)
|
||||||
|
);
|
||||||
|
|
||||||
|
return titleMatch || contentMatch || tagMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getNoteById(id: string): Note | undefined {
|
||||||
|
return this.notes.find(n => n.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllTags(): string[] {
|
||||||
|
const tagSet = new Set<string>();
|
||||||
|
|
||||||
|
this.notes.forEach(note => {
|
||||||
|
note.tags.forEach(tag => tagSet.add(tag));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(tagSet).sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTE_SERVICE_KEY = Symbol('NOTE_SERVICE');
|
||||||
|
|
||||||
|
export function setNoteService(userId: string) {
|
||||||
|
return setContext(NOTE_SERVICE_KEY, new NoteService(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNoteService() {
|
||||||
|
return getContext<ReturnType<typeof setNoteService>>(NOTE_SERVICE_KEY);
|
||||||
|
}
|
||||||
@@ -72,9 +72,33 @@ export const schema = S.Collections({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
schema: S.Schema({
|
||||||
|
id: S.Id(),
|
||||||
|
userId: S.String(),
|
||||||
|
title: S.String({ default: '' }),
|
||||||
|
content: S.String({ default: '' }),
|
||||||
|
pinned: S.Boolean({ default: false }),
|
||||||
|
archived: S.Boolean({ default: false }),
|
||||||
|
deleted: S.Boolean({ default: false }),
|
||||||
|
tags: S.Set(S.String(), { default: [] }),
|
||||||
|
createdAt: S.Date({ default: S.Default.now() }),
|
||||||
|
updatedAt: S.Date({ default: S.Default.now() }),
|
||||||
|
deletedAt: S.Date({ nullable: true }),
|
||||||
|
}),
|
||||||
|
permissions: {
|
||||||
|
owner: {
|
||||||
|
read: { filter: [['userId', '=', '$userId']] },
|
||||||
|
insert: { filter: [['userId', '=', '$userId']] },
|
||||||
|
update: { filter: [['userId', '=', '$userId']] },
|
||||||
|
delete: { filter: [['userId', '=', '$userId']] }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use the `Entity` type to extract clean types for your collections
|
// Use the `Entity` type to extract clean types for your collections
|
||||||
export type Habit = Entity<typeof schema, 'habits'>;
|
export type Habit = Entity<typeof schema, 'habits'>;
|
||||||
export type HabitCompletion = Entity<typeof schema, 'habit_completions'>;
|
export type HabitCompletion = Entity<typeof schema, 'habit_completions'>;
|
||||||
|
export type Note = Entity<typeof schema, 'notes'>;
|
||||||
@@ -76,7 +76,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const displayUnit = $derived(getDisplayUnit(unit));
|
const displayUnit = $derived(getDisplayUnit(unit));
|
||||||
|
|
||||||
|
// Dynamic font size based on digit count
|
||||||
|
const fontSize = $derived.by(() => {
|
||||||
|
const digits = count.toString().length;
|
||||||
|
if (digits <= 2) return 'text-lg';
|
||||||
|
if (digits === 3) return 'text-base';
|
||||||
|
if (digits === 4) return 'text-sm';
|
||||||
|
return 'text-xs';
|
||||||
|
});
|
||||||
|
|
||||||
let checked = $derived(count >= target);
|
let checked = $derived(count >= target);
|
||||||
let showConfetti = $state(false);
|
let showConfetti = $state(false);
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
@@ -188,7 +197,7 @@
|
|||||||
{#if displayUnit.text && displayUnit.isShort}
|
{#if displayUnit.text && displayUnit.isShort}
|
||||||
<!-- Single letter unit: horizontal layout -->
|
<!-- Single letter unit: horizontal layout -->
|
||||||
<div class="flex items-baseline gap-1">
|
<div class="flex items-baseline gap-1">
|
||||||
<NumberFlow value={count} class={cn("font-semibold text-lg", hideNumber && "invisible")} />
|
<NumberFlow value={count} class={cn("font-semibold tabular-nums", fontSize, hideNumber && "invisible")} />
|
||||||
{#if count > 0}
|
{#if count > 0}
|
||||||
<span class="text-[11px] opacity-75 font-medium uppercase">{displayUnit.text}</span>
|
<span class="text-[11px] opacity-75 font-medium uppercase">{displayUnit.text}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -196,14 +205,14 @@
|
|||||||
{:else if displayUnit.text && !displayUnit.isShort}
|
{:else if displayUnit.text && !displayUnit.isShort}
|
||||||
<!-- 2-3 letter unit: vertical layout -->
|
<!-- 2-3 letter unit: vertical layout -->
|
||||||
<div class="flex flex-col items-center justify-center">
|
<div class="flex flex-col items-center justify-center">
|
||||||
<NumberFlow value={count} class={cn("font-semibold text-lg leading-tight", hideNumber && "invisible")} />
|
<NumberFlow value={count} class={cn("font-semibold tabular-nums leading-tight", fontSize, hideNumber && "invisible")} />
|
||||||
{#if count > 0}
|
{#if count > 0}
|
||||||
<span class="text-[11px] opacity-75 font-medium lowercase leading-none -mt-1">{displayUnit.text}</span>
|
<span class="text-[11px] opacity-75 font-medium lowercase leading-none -mt-1">{displayUnit.text}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- No unit: just the number -->
|
<!-- No unit: just the number -->
|
||||||
<NumberFlow value={count} class={cn("font-semibold", hideNumber && "invisible")} />
|
<NumberFlow value={count} class={cn("font-semibold tabular-nums", fontSize, hideNumber && "invisible")} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else if checked}
|
{:else if checked}
|
||||||
<Check class="text-primary-foreground" />
|
<Check class="text-primary-foreground" />
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { setNoteService } from '$lib/context/notes.svelte';
|
||||||
|
|
||||||
|
// Initialize note service for all notes routes
|
||||||
|
setNoteService('default-user');
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getNoteService } from '$lib/context/notes.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||||
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
import { Plus, LayoutGrid, List } from '@lucide/svelte';
|
||||||
|
import NotesGrid from './NotesGrid.svelte';
|
||||||
|
import NoteSearchBar from './NoteSearchBar.svelte';
|
||||||
|
|
||||||
|
// Get note service from context
|
||||||
|
const noteService = getNoteService();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let currentFilter = $state<'all' | 'pinned' | 'archived' | 'trash'>('all');
|
||||||
|
let view = $state<'grid' | 'list'>('grid');
|
||||||
|
|
||||||
|
// Get filtered notes based on current tab
|
||||||
|
const filteredNotes = $derived(() => {
|
||||||
|
let notes = noteService.activeNotes;
|
||||||
|
|
||||||
|
switch (currentFilter) {
|
||||||
|
case 'pinned':
|
||||||
|
notes = noteService.pinnedNotes;
|
||||||
|
break;
|
||||||
|
case 'archived':
|
||||||
|
notes = noteService.archivedNotes;
|
||||||
|
break;
|
||||||
|
case 'trash':
|
||||||
|
notes = noteService.trashedNotes;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
return noteService.searchNotes(searchQuery, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createNewNote() {
|
||||||
|
const note = await noteService.createNote({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
tags: new Set()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait a bit for the subscription to update
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
goto(`/notes/${note.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNote(noteId: string) {
|
||||||
|
goto(`/notes/${noteId}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full container mx-auto p-6 space-y-6">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight text-foreground">Notes</h1>
|
||||||
|
<p class="text-muted-foreground">Capture your thoughts with markdown</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- View Toggle -->
|
||||||
|
<div class="flex items-center border rounded-md">
|
||||||
|
<Button
|
||||||
|
variant={view === 'grid' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={() => (view = 'grid')}
|
||||||
|
aria-label="Grid view"
|
||||||
|
>
|
||||||
|
<LayoutGrid class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={view === 'list' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={() => (view = 'list')}
|
||||||
|
aria-label="List view"
|
||||||
|
>
|
||||||
|
<List class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Note Button -->
|
||||||
|
<Button onclick={createNewNote} class="gap-2">
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
New Note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Search and Filters -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<NoteSearchBar bind:value={searchQuery} />
|
||||||
|
|
||||||
|
<Tabs.Root value={currentFilter} onValueChange={(v) => (currentFilter = v as any)}>
|
||||||
|
<Tabs.List class="grid w-full grid-cols-4">
|
||||||
|
<Tabs.Trigger value="all">
|
||||||
|
All
|
||||||
|
{#if !searchQuery}
|
||||||
|
<span class="ml-1 text-xs text-muted-foreground">
|
||||||
|
({noteService.activeNotes.length})
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="pinned">
|
||||||
|
Pinned
|
||||||
|
{#if !searchQuery}
|
||||||
|
<span class="ml-1 text-xs text-muted-foreground">
|
||||||
|
({noteService.pinnedNotes.length})
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="archived">
|
||||||
|
Archived
|
||||||
|
{#if !searchQuery}
|
||||||
|
<span class="ml-1 text-xs text-muted-foreground">
|
||||||
|
({noteService.archivedNotes.length})
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="trash">
|
||||||
|
Trash
|
||||||
|
{#if !searchQuery}
|
||||||
|
<span class="ml-1 text-xs text-muted-foreground">
|
||||||
|
({noteService.trashedNotes.length})
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes Grid -->
|
||||||
|
<NotesGrid
|
||||||
|
notes={filteredNotes()}
|
||||||
|
{view}
|
||||||
|
loading={noteService.loading}
|
||||||
|
emptyMessage={searchQuery
|
||||||
|
? 'No notes match your search'
|
||||||
|
: currentFilter === 'pinned'
|
||||||
|
? 'No pinned notes'
|
||||||
|
: currentFilter === 'archived'
|
||||||
|
? 'No archived notes'
|
||||||
|
: currentFilter === 'trash'
|
||||||
|
? 'Trash is empty'
|
||||||
|
: 'No notes yet'}
|
||||||
|
onNoteClick={(note) => openNote(note.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||||
|
import {
|
||||||
|
EllipsisVertical,
|
||||||
|
Pin,
|
||||||
|
Archive,
|
||||||
|
ArchiveRestore,
|
||||||
|
Trash2,
|
||||||
|
RotateCcw,
|
||||||
|
XCircle
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import { getNoteService } from '$lib/context/notes.svelte';
|
||||||
|
import type { Note } from '$lib/triplit/schema';
|
||||||
|
|
||||||
|
const noteService = getNoteService();
|
||||||
|
|
||||||
|
let { note }: { note: Note } = $props();
|
||||||
|
|
||||||
|
let deleteDialog = $state(false);
|
||||||
|
let permanentDeleteDialog = $state(false);
|
||||||
|
|
||||||
|
async function handlePin() {
|
||||||
|
await noteService.pinNote(note.id, !note.pinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleArchive() {
|
||||||
|
await noteService.archiveNote(note.id, !note.archived);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
await noteService.deleteNote(note.id);
|
||||||
|
deleteDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestore() {
|
||||||
|
await noteService.restoreNote(note.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePermanentDelete() {
|
||||||
|
await noteService.permanentDelete(note.id);
|
||||||
|
permanentDeleteDialog = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<Button variant="ghost" size="icon-sm">
|
||||||
|
<EllipsisVertical class="h-4 w-4" />
|
||||||
|
<span class="sr-only">Note actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content class="w-48">
|
||||||
|
<DropdownMenu.Group>
|
||||||
|
<DropdownMenu.GroupHeading>Actions</DropdownMenu.GroupHeading>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
|
{#if !note.deleted}
|
||||||
|
<!-- Pin/Unpin -->
|
||||||
|
<DropdownMenu.Item onclick={handlePin}>
|
||||||
|
<Pin class="h-4 w-4" />
|
||||||
|
{note.pinned ? 'Unpin' : 'Pin note'}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
|
<!-- Archive/Unarchive -->
|
||||||
|
{#if !note.archived}
|
||||||
|
<DropdownMenu.Item onclick={handleArchive}>
|
||||||
|
<Archive class="h-4 w-4" />
|
||||||
|
Archive
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{:else}
|
||||||
|
<DropdownMenu.Item onclick={handleArchive}>
|
||||||
|
<ArchiveRestore class="h-4 w-4" />
|
||||||
|
Unarchive
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<DropdownMenu.Item onclick={() => (deleteDialog = true)}>
|
||||||
|
<Trash2 class="h-4 w-4 text-destructive" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{:else}
|
||||||
|
<!-- Restore from trash -->
|
||||||
|
<DropdownMenu.Item onclick={handleRestore}>
|
||||||
|
<RotateCcw class="h-4 w-4" />
|
||||||
|
Restore
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
|
<!-- Permanent delete -->
|
||||||
|
<DropdownMenu.Item onclick={() => (permanentDeleteDialog = true)}>
|
||||||
|
<XCircle class="h-4 w-4 text-destructive" />
|
||||||
|
<span class="text-destructive">Delete forever</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<AlertDialog.Root bind:open={deleteDialog}>
|
||||||
|
<AlertDialog.Content>
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.Title>Move to trash?</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
This note will be moved to trash. You can restore it within 30 days.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
</AlertDialog.Header>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||||
|
<Button onclick={handleDelete} variant="destructive">Move to trash</Button>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
|
||||||
|
<!-- Permanent Delete Confirmation Dialog -->
|
||||||
|
<AlertDialog.Root bind:open={permanentDeleteDialog}>
|
||||||
|
<AlertDialog.Content>
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.Title>Delete permanently?</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
This action cannot be undone. This will permanently delete the note.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
</AlertDialog.Header>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||||
|
<Button onclick={handlePermanentDelete} variant="destructive">Delete forever</Button>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import { Pin } from '@lucide/svelte';
|
||||||
|
import type { Note } from '$lib/triplit/schema';
|
||||||
|
import NoteActionMenu from './NoteActionMenu.svelte';
|
||||||
|
import { Carta, Markdown } from 'carta-md';
|
||||||
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
|
||||||
|
let { note, view = 'grid', onclick }: { note: Note; view?: 'grid' | 'list'; onclick?: () => void } = $props();
|
||||||
|
|
||||||
|
const carta = new Carta({ sanitizer: DOMPurify.sanitize });
|
||||||
|
|
||||||
|
// Get first line of content as title if title is empty
|
||||||
|
const displayTitle = $derived(note.title || note.content.split('\n')[0] || 'Untitled');
|
||||||
|
|
||||||
|
// Truncate content for preview
|
||||||
|
const previewContent = $derived(() => {
|
||||||
|
const lines = view === 'grid' ? 3 : 2;
|
||||||
|
const contentLines = note.content.split('\n').slice(0, lines);
|
||||||
|
const preview = contentLines.join('\n');
|
||||||
|
return preview.length > 150 ? preview.substring(0, 150) + '...' : preview;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format relative time
|
||||||
|
function formatRelativeTime(date: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
|
||||||
|
if (years > 0) return `${years}y ago`;
|
||||||
|
if (months > 0) return `${months}mo ago`;
|
||||||
|
if (days > 0) return `${days}d ago`;
|
||||||
|
if (hours > 0) return `${hours}h ago`;
|
||||||
|
if (minutes > 0) return `${minutes}m ago`;
|
||||||
|
return 'Just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = $derived(Array.from(note.tags));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root
|
||||||
|
class="cursor-pointer transition-all hover:shadow-md gap-2 {view === 'list' ? 'flex flex-row' : ''}"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
{onclick}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onclick?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2 px-6">
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{#if note.pinned}
|
||||||
|
<Pin class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
{/if}
|
||||||
|
<h3 class="font-semibold text-base line-clamp-1">{displayTitle}</h3>
|
||||||
|
</div>
|
||||||
|
<div onclick={(e) => e.stopPropagation()}>
|
||||||
|
<NoteActionMenu {note} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Content class="flex-1">
|
||||||
|
<!-- Content Preview -->
|
||||||
|
{#if note.content}
|
||||||
|
<div class="note-preview text-sm text-muted-foreground {view === 'grid' ? 'line-clamp-3' : 'line-clamp-2'} overflow-hidden">
|
||||||
|
<Markdown {carta} value={previewContent()} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
{#if tags.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-1 mt-3">
|
||||||
|
{#each tags.slice(0, 3) as tag (tag)}
|
||||||
|
<Badge variant="outline" class="text-xs">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
{#if tags.length > 3}
|
||||||
|
<Badge variant="outline" class="text-xs">
|
||||||
|
+{tags.length - 3}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
|
||||||
|
<Card.Footer>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{formatRelativeTime(note.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.note-preview :global(*) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Carta, MarkdownEditor } from 'carta-md';
|
||||||
|
import DOMPurify from 'isomorphic-dompurify';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
import { getNoteService } from '$lib/context/notes.svelte';
|
||||||
|
|
||||||
|
// Import Carta styles
|
||||||
|
import 'carta-md/default.css';
|
||||||
|
// import 'carta-md/light.css';
|
||||||
|
|
||||||
|
const noteService = getNoteService();
|
||||||
|
|
||||||
|
let { noteId }: { noteId: string } = $props();
|
||||||
|
|
||||||
|
let note = $derived(noteService.getNoteById(noteId));
|
||||||
|
let title = $state('');
|
||||||
|
let content = $state('');
|
||||||
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let lastSaved = $state<Date | null>(null);
|
||||||
|
let isSaving = $state(false);
|
||||||
|
let hasUnsavedChanges = $state(false);
|
||||||
|
let initializedNoteId = $state<string | null>(null);
|
||||||
|
let lastSavedTitle = $state('');
|
||||||
|
let lastSavedContent = $state('');
|
||||||
|
|
||||||
|
// Initialize Carta with DOMPurify sanitization
|
||||||
|
// const carta = new Carta({
|
||||||
|
// sanitizer: DOMPurify.sanitize
|
||||||
|
// });
|
||||||
|
|
||||||
|
const carta = new Carta({
|
||||||
|
sanitizer: false,
|
||||||
|
extensions: [
|
||||||
|
// attachment({
|
||||||
|
// async upload() {
|
||||||
|
// return 'some-url-from-server.xyz';
|
||||||
|
// }
|
||||||
|
// }),
|
||||||
|
// emoji(),
|
||||||
|
// slash(),
|
||||||
|
// code()
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize title and content only when noteId changes
|
||||||
|
$effect(() => {
|
||||||
|
if (note && noteId !== initializedNoteId) {
|
||||||
|
title = note.title;
|
||||||
|
content = note.content;
|
||||||
|
lastSavedTitle = note.title;
|
||||||
|
lastSavedContent = note.content;
|
||||||
|
initializedNoteId = noteId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-save functionality (500ms debounce after last change)
|
||||||
|
async function saveNote() {
|
||||||
|
if (!note) return;
|
||||||
|
|
||||||
|
hasUnsavedChanges = false;
|
||||||
|
isSaving = true;
|
||||||
|
try {
|
||||||
|
await noteService.updateNote(note.id, {
|
||||||
|
title: title.trim(),
|
||||||
|
content: content.trim()
|
||||||
|
});
|
||||||
|
lastSavedTitle = title.trim();
|
||||||
|
lastSavedContent = content.trim();
|
||||||
|
lastSaved = new Date();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save note:', err);
|
||||||
|
hasUnsavedChanges = true; // Restore unsaved state on error
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debounceSave() {
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
|
||||||
|
if (saveTimeout) {
|
||||||
|
clearTimeout(saveTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTimeout = setTimeout(() => {
|
||||||
|
saveNote();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save on title change (debounced - waits 500ms after last keystroke)
|
||||||
|
$effect(() => {
|
||||||
|
if (initializedNoteId && title !== lastSavedTitle) {
|
||||||
|
debounceSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save on content change (debounced - waits 500ms after last keystroke)
|
||||||
|
$effect(() => {
|
||||||
|
if (initializedNoteId && content !== lastSavedContent) {
|
||||||
|
debounceSave();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
onMount(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeout) {
|
||||||
|
clearTimeout(saveTimeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format last saved time
|
||||||
|
function formatSaveTime(date: Date | null): string {
|
||||||
|
if (!date) return '';
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
|
||||||
|
if (seconds < 5) return 'Saved just now';
|
||||||
|
if (seconds < 60) return `Saved ${seconds}s ago`;
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `Saved ${minutes}m ago`;
|
||||||
|
|
||||||
|
return `Saved at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if note}
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<!-- Title Input -->
|
||||||
|
<div class="p-4 pb-0">
|
||||||
|
<Input
|
||||||
|
bind:value={title}
|
||||||
|
placeholder="Note title..."
|
||||||
|
class="text-2xl font-semibold border-none shadow-none px-0 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator class="my-4" />
|
||||||
|
|
||||||
|
<!-- Carta Editor -->
|
||||||
|
<div class="flex-1 px-4 pb-4 overflow-hidden">
|
||||||
|
<MarkdownEditor {carta} bind:value={content} mode="tabs" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save Status -->
|
||||||
|
<div class="px-4 py-2 border-t bg-muted/30">
|
||||||
|
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{#if isSaving}
|
||||||
|
Saving...
|
||||||
|
{:else if hasUnsavedChanges}
|
||||||
|
Unsaved changes
|
||||||
|
{:else if lastSaved}
|
||||||
|
{formatSaveTime(lastSaved)}
|
||||||
|
{:else}
|
||||||
|
No changes
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{content.length} characters
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="h-full flex items-center justify-center">
|
||||||
|
<p class="text-muted-foreground">Note not found</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Search, X } from '@lucide/svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
placeholder = 'Search notes...'
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative w-full">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
bind:value
|
||||||
|
{placeholder}
|
||||||
|
class="pl-9 pr-9"
|
||||||
|
type="search"
|
||||||
|
/>
|
||||||
|
{#if value}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="absolute right-1 top-1/2 -translate-y-1/2"
|
||||||
|
onclick={clear}
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
<span class="sr-only">Clear search</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Note } from '$lib/triplit/schema';
|
||||||
|
import NoteCard from './NoteCard.svelte';
|
||||||
|
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
notes,
|
||||||
|
view = 'grid',
|
||||||
|
loading = false,
|
||||||
|
emptyMessage = 'No notes found',
|
||||||
|
onNoteClick
|
||||||
|
}: {
|
||||||
|
notes: Note[];
|
||||||
|
view?: 'grid' | 'list';
|
||||||
|
loading?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
onNoteClick?: (note: Note) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<!-- Loading Skeletons -->
|
||||||
|
<div class="grid {view === 'grid' ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' : 'grid-cols-1'} gap-4">
|
||||||
|
{#each Array(6) as _}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Skeleton class="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if notes.length === 0}
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-muted mb-4">
|
||||||
|
<svg
|
||||||
|
class="h-10 w-10 text-muted-foreground"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">{emptyMessage}</h3>
|
||||||
|
<p class="text-muted-foreground max-w-sm">
|
||||||
|
{#if emptyMessage === 'No notes found'}
|
||||||
|
Create your first note to get started.
|
||||||
|
{:else}
|
||||||
|
{emptyMessage}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Notes Grid/List -->
|
||||||
|
<div
|
||||||
|
class="grid {view === 'grid'
|
||||||
|
? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
||||||
|
: 'grid-cols-1'} gap-4"
|
||||||
|
>
|
||||||
|
{#each notes as note (note.id)}
|
||||||
|
<NoteCard {note} {view} onclick={() => onNoteClick?.(note)} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { X } from '@lucide/svelte';
|
||||||
|
import { getNoteService } from '$lib/context/notes.svelte';
|
||||||
|
import type { Note } from '$lib/triplit/schema';
|
||||||
|
|
||||||
|
const noteService = getNoteService();
|
||||||
|
|
||||||
|
let { note }: { note: Note } = $props();
|
||||||
|
|
||||||
|
let tagInput = $state('');
|
||||||
|
let tags = $derived(Array.from(note.tags));
|
||||||
|
|
||||||
|
async function addTag() {
|
||||||
|
const tag = tagInput.trim();
|
||||||
|
if (!tag || tags.includes(tag)) {
|
||||||
|
tagInput = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await noteService.addTag(note.id, tag);
|
||||||
|
tagInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTag(tag: string) {
|
||||||
|
await noteService.removeTag(note.id, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- Tag Input -->
|
||||||
|
<Input
|
||||||
|
bind:value={tagInput}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
placeholder="Add a tag..."
|
||||||
|
class="text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Tag List -->
|
||||||
|
{#if tags.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each tags as tag (tag)}
|
||||||
|
<Badge variant="secondary" class="gap-1">
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeTag(tag)}
|
||||||
|
class="ml-1 hover:text-destructive transition-colors"
|
||||||
|
aria-label="Remove tag"
|
||||||
|
>
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getNoteService } from '$lib/context/notes.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
import { ArrowLeft, Pin, Archive, ArchiveRestore, MoreVertical } from '@lucide/svelte';
|
||||||
|
import NoteEditor from '../NoteEditor.svelte';
|
||||||
|
import NoteActionMenu from '../NoteActionMenu.svelte';
|
||||||
|
import TagInput from '../TagInput.svelte';
|
||||||
|
|
||||||
|
const noteService = getNoteService();
|
||||||
|
const noteId = $derived($page.params.id);
|
||||||
|
const note = $derived(noteService.getNoteById(noteId));
|
||||||
|
|
||||||
|
let showTagDialog = $state(false);
|
||||||
|
|
||||||
|
async function handlePin() {
|
||||||
|
if (!note) return;
|
||||||
|
await noteService.pinNote(note.id, !note.pinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleArchive() {
|
||||||
|
if (!note) return;
|
||||||
|
await noteService.archiveNote(note.id, !note.archived);
|
||||||
|
goto('/notes');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
goto('/notes');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if note}
|
||||||
|
<div class="h-screen flex flex-col">
|
||||||
|
<!-- Top App Bar -->
|
||||||
|
<div class="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div class="container mx-auto px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={goBack}>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
<span class="sr-only">Back to notes</span>
|
||||||
|
</Button>
|
||||||
|
<span class="text-sm text-muted-foreground">Back to notes</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Pin Button -->
|
||||||
|
<Button
|
||||||
|
variant={note.pinned ? 'secondary' : 'ghost'}
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={handlePin}
|
||||||
|
title={note.pinned ? 'Unpin note' : 'Pin note'}
|
||||||
|
>
|
||||||
|
<Pin class="h-4 w-4 {note.pinned ? 'fill-current' : ''}" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Archive Button -->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={handleArchive}
|
||||||
|
title={note.archived ? 'Unarchive note' : 'Archive note'}
|
||||||
|
>
|
||||||
|
{#if note.archived}
|
||||||
|
<ArchiveRestore class="h-4 w-4" />
|
||||||
|
{:else}
|
||||||
|
<Archive class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Tags Dialog Trigger -->
|
||||||
|
<Button variant="ghost" size="sm" onclick={() => (showTagDialog = true)}>
|
||||||
|
Tags
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" class="h-6" />
|
||||||
|
|
||||||
|
<!-- More Options -->
|
||||||
|
<NoteActionMenu {note} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor -->
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<NoteEditor {noteId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags Dialog -->
|
||||||
|
<Dialog.Root bind:open={showTagDialog}>
|
||||||
|
<Dialog.Content class="sm:max-w-md">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Manage Tags</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Add or remove tags to organize your notes.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<div class="py-4">
|
||||||
|
<TagInput {note} />
|
||||||
|
</div>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button onclick={() => (showTagDialog = false)}>Done</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
{:else}
|
||||||
|
<div class="h-screen flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">Note not found</h2>
|
||||||
|
<p class="text-muted-foreground mb-4">The note you're looking for doesn't exist.</p>
|
||||||
|
<Button onclick={goBack}>
|
||||||
|
<ArrowLeft class="h-4 w-4 mr-2" />
|
||||||
|
Back to notes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getNoteService } from '$lib/context/notes.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
import { ArrowLeft, LayoutGrid, List } from '@lucide/svelte';
|
||||||
|
import NotesGrid from '../NotesGrid.svelte';
|
||||||
|
import NoteSearchBar from '../NoteSearchBar.svelte';
|
||||||
|
|
||||||
|
const noteService = getNoteService();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let view = $state<'grid' | 'list'>('grid');
|
||||||
|
|
||||||
|
const filteredNotes = $derived(() => {
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
return noteService.searchNotes(searchQuery, noteService.archivedNotes);
|
||||||
|
}
|
||||||
|
return noteService.archivedNotes;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openNote(noteId: string) {
|
||||||
|
goto(`/notes/${noteId}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full container mx-auto p-6 space-y-6">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={() => goto('/notes')}>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
<span class="sr-only">Back to notes</span>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight text-foreground">Archived Notes</h1>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{noteService.archivedNotes.length}
|
||||||
|
{noteService.archivedNotes.length === 1 ? 'note' : 'notes'} archived
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Toggle -->
|
||||||
|
<div class="flex items-center border rounded-md">
|
||||||
|
<Button
|
||||||
|
variant={view === 'grid' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={() => (view = 'grid')}
|
||||||
|
aria-label="Grid view"
|
||||||
|
>
|
||||||
|
<LayoutGrid class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={view === 'list' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={() => (view = 'list')}
|
||||||
|
aria-label="List view"
|
||||||
|
>
|
||||||
|
<List class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<NoteSearchBar bind:value={searchQuery} placeholder="Search archived notes..." />
|
||||||
|
|
||||||
|
<!-- Notes Grid -->
|
||||||
|
<NotesGrid
|
||||||
|
notes={filteredNotes()}
|
||||||
|
{view}
|
||||||
|
loading={noteService.loading}
|
||||||
|
emptyMessage={searchQuery ? 'No archived notes match your search' : 'No archived notes'}
|
||||||
|
onNoteClick={(note) => openNote(note.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getNoteService } from '$lib/context/notes.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||||
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
import { ArrowLeft, LayoutGrid, List, Trash2 } from '@lucide/svelte';
|
||||||
|
import NotesGrid from '../NotesGrid.svelte';
|
||||||
|
import NoteSearchBar from '../NoteSearchBar.svelte';
|
||||||
|
|
||||||
|
const noteService = getNoteService();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let view = $state<'grid' | 'list'>('grid');
|
||||||
|
let emptyTrashDialog = $state(false);
|
||||||
|
|
||||||
|
const filteredNotes = $derived(() => {
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
return noteService.searchNotes(searchQuery, noteService.trashedNotes);
|
||||||
|
}
|
||||||
|
return noteService.trashedNotes;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleEmptyTrash() {
|
||||||
|
await noteService.emptyTrash();
|
||||||
|
emptyTrashDialog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNote(noteId: string) {
|
||||||
|
goto(`/notes/${noteId}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full container mx-auto p-6 space-y-6">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={() => goto('/notes')}>
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
<span class="sr-only">Back to notes</span>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight text-foreground">Trash</h1>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{noteService.trashedNotes.length}
|
||||||
|
{noteService.trashedNotes.length === 1 ? 'note' : 'notes'} in trash • Auto-delete after
|
||||||
|
30 days
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- View Toggle -->
|
||||||
|
<div class="flex items-center border rounded-md">
|
||||||
|
<Button
|
||||||
|
variant={view === 'grid' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={() => (view = 'grid')}
|
||||||
|
aria-label="Grid view"
|
||||||
|
>
|
||||||
|
<LayoutGrid class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={view === 'list' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={() => (view = 'list')}
|
||||||
|
aria-label="List view"
|
||||||
|
>
|
||||||
|
<List class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty Trash Button -->
|
||||||
|
{#if noteService.trashedNotes.length > 0}
|
||||||
|
<Button variant="destructive" onclick={() => (emptyTrashDialog = true)} class="gap-2">
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
Empty Trash
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<NoteSearchBar bind:value={searchQuery} placeholder="Search trash..." />
|
||||||
|
|
||||||
|
<!-- Notes Grid -->
|
||||||
|
<NotesGrid
|
||||||
|
notes={filteredNotes()}
|
||||||
|
{view}
|
||||||
|
loading={noteService.loading}
|
||||||
|
emptyMessage={searchQuery ? 'No trashed notes match your search' : 'Trash is empty'}
|
||||||
|
onNoteClick={(note) => openNote(note.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty Trash Confirmation Dialog -->
|
||||||
|
<AlertDialog.Root bind:open={emptyTrashDialog}>
|
||||||
|
<AlertDialog.Content>
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.Title>Empty trash?</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
This will permanently delete all notes in the trash. This action cannot be undone.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
</AlertDialog.Header>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||||
|
<Button onclick={handleEmptyTrash} variant="destructive">Empty trash</Button>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
@@ -118,4 +118,9 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Revert Tailwind's Preflight resets inside Carta's markdown renderer */
|
||||||
|
.carta-renderer * {
|
||||||
|
all: revert;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user