This commit is contained in:
Stefan ATZLESBERGER
2026-04-16 16:05:22 +02:00
parent 14ed445328
commit c2b20a9f10
11 changed files with 326 additions and 14 deletions
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/** Styled native `<input type="date">` (Kalender-Popup bleibt browserabhängig). */
const DateInput = React.forwardRef<HTMLInputElement, Omit<React.ComponentProps<"input">, "type">>(
({ className, ...props }, ref) => (
<input
ref={ref}
type="date"
data-slot="date-input"
className={cn(
"date-field",
"text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 font-mono text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"focus:border-ring focus:ring-[3px] focus:ring-ring/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
),
);
DateInput.displayName = "DateInput";
export { DateInput };
+2
View File
@@ -1,6 +1,8 @@
export const SORT_OPTIONS = [ export const SORT_OPTIONS = [
{ value: "updated_desc", label: "Zuletzt geändert (neu zuerst)" }, { value: "updated_desc", label: "Zuletzt geändert (neu zuerst)" },
{ value: "updated_asc", label: "Zuletzt geändert (alt zuerst)" }, { value: "updated_asc", label: "Zuletzt geändert (alt zuerst)" },
{ value: "deadline_asc", label: "Deadline (früheste zuerst)" },
{ value: "deadline_desc", label: "Deadline (späteste zuerst)" },
{ value: "title_asc", label: "Titel A–Z" }, { value: "title_asc", label: "Titel A–Z" },
{ value: "title_desc", label: "Titel Z–A" }, { value: "title_desc", label: "Titel Z–A" },
] as const; ] as const;
+48 -4
View File
@@ -1,8 +1,10 @@
import { Pencil, Trash2 } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { Snippet } from "@/types/snippet"; import type { Snippet } from "@/types/snippet";
import { deadlineToDateInput } from "@/lib/snippetDeadline";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { SnippetDeadlineBadge } from "@/features/snippets/components/SnippetDeadlineBadge";
import { SnippetEditDialog } from "@/features/snippets/components/SnippetEditDialog"; import { SnippetEditDialog } from "@/features/snippets/components/SnippetEditDialog";
import { TagPill } from "@/features/snippets/components/TagPill"; import { TagPill } from "@/features/snippets/components/TagPill";
import { colorForTagName, tagsFromString } from "@/features/snippets/lib/tagFieldUtils"; import { colorForTagName, tagsFromString } from "@/features/snippets/lib/tagFieldUtils";
@@ -18,12 +20,14 @@ type Props = {
}; };
export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onReplace, onRemove }: Props) { export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onReplace, onRemove }: Props) {
const pristineEmpty = !snippet.body.trim() && (snippet.tags?.length ?? 0) === 0; const pristineEmpty =
!snippet.body.trim() && (snippet.tags?.length ?? 0) === 0 && !snippet.deadline_at;
const [dialogOpen, setDialogOpen] = useState(pristineEmpty); const [dialogOpen, setDialogOpen] = useState(pristineEmpty);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [body, setBody] = useState(() => snippet.body ?? ""); const [body, setBody] = useState(() => snippet.body ?? "");
const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", ")); const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", "));
const [deadlineDate, setDeadlineDate] = useState(() => deadlineToDateInput(snippet.deadline_at));
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -33,11 +37,13 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
if (dialogOpen) return; if (dialogOpen) return;
setBody(snippet.body ?? ""); setBody(snippet.body ?? "");
setTagsStr((snippet.tags ?? []).join(", ")); setTagsStr((snippet.tags ?? []).join(", "));
}, [dialogOpen, snippet.id, snippet.updated_at, snippet.body, snippet.tags]); setDeadlineDate(deadlineToDateInput(snippet.deadline_at));
}, [dialogOpen, snippet.id, snippet.updated_at, snippet.body, snippet.tags, snippet.deadline_at]);
const hydrateFromSnippet = () => { const hydrateFromSnippet = () => {
setBody(snippet.body ?? ""); setBody(snippet.body ?? "");
setTagsStr((snippet.tags ?? []).join(", ")); setTagsStr((snippet.tags ?? []).join(", "));
setDeadlineDate(deadlineToDateInput(snippet.deadline_at));
setDirty(false); setDirty(false);
setError(null); setError(null);
}; };
@@ -68,6 +74,7 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
title: null, title: null,
body, body,
tags: tagsFromString(tagsStr), tags: tagsFromString(tagsStr),
deadline_at: deadlineDate.trim() === "" ? null : deadlineDate.trim(),
}), }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -85,6 +92,29 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
} }
}; };
async function clearDeadlineRemote() {
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/snippets/${snippet.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deadline_at: null }),
});
if (!res.ok) {
const err = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(err?.error ?? res.statusText);
}
const updated = (await res.json()) as Snippet;
onReplace(updated);
setDeadlineDate("");
} catch (e) {
setError(e instanceof Error ? e.message : "Deadline konnte nicht entfernt werden");
} finally {
setBusy(false);
}
}
async function deleteRemote(askConfirm: boolean) { async function deleteRemote(askConfirm: boolean) {
if (askConfirm && !window.confirm("Snippet wirklich löschen?")) return; if (askConfirm && !window.confirm("Snippet wirklich löschen?")) return;
setBusy(true); setBusy(true);
@@ -108,7 +138,7 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
return ( return (
<> <>
<Card className="gap-0 border-border/80 py-0 text-left shadow-md ring-1 ring-black/5 dark:ring-white/10"> <Card className="gap-0 border-border/80 py-0 text-left shadow-md ring-1 ring-black/5 dark:ring-white/10">
<CardContent className="px-3 pb-1 pt-2"> <CardContent className="space-y-1.5 px-3 pb-1 pt-2">
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -125,8 +155,17 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
<div className="snippet-md overflow-auto" dangerouslySetInnerHTML={{ __html: viewHtml }} /> <div className="snippet-md overflow-auto" dangerouslySetInnerHTML={{ __html: viewHtml }} />
</div> </div>
</div> </div>
{snippet.deadline_at ? (
<div className="border-t border-border/70 pt-1.5">
<SnippetDeadlineBadge
deadlineAt={snippet.deadline_at}
busy={busy}
onMarkDone={() => void clearDeadlineRemote()}
/>
</div>
) : null}
</CardContent> </CardContent>
<CardFooter className="flex flex-row items-center gap-2 border-t border-border/80 px-3 py-1"> <CardFooter className="flex flex-row flex-wrap items-center gap-2 border-t border-border/80 px-3 py-1">
<div className="flex min-h-7 min-w-0 flex-1 flex-wrap items-center gap-1"> <div className="flex min-h-7 min-w-0 flex-1 flex-wrap items-center gap-1">
{snippet.tags.length ? ( {snippet.tags.length ? (
snippet.tags.map((t, i) => ( snippet.tags.map((t, i) => (
@@ -192,6 +231,11 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
markDirty(); markDirty();
setTagsStr(v); setTagsStr(v);
}} }}
deadlineDate={deadlineDate}
onDeadlineChange={v => {
markDirty();
setDeadlineDate(v);
}}
busy={busy} busy={busy}
error={error} error={error}
onSave={() => void save()} onSave={() => void save()}
@@ -0,0 +1,44 @@
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { deadlineBadgeClass, deadlineTone, formatDeadlineLabel } from "@/lib/snippetDeadline";
import { Button } from "@/components/ui/button";
type Props = {
deadlineAt: string;
busy?: boolean;
onMarkDone?: () => void;
};
export function SnippetDeadlineBadge({ deadlineAt, busy, onMarkDone }: Props) {
const tone = deadlineTone(deadlineAt);
return (
<div className="flex items-center gap-0.5">
<span
className={cn(
"inline-flex max-w-full items-center rounded-md border-2 px-2 py-px text-[10px] font-medium tabular-nums leading-tight",
deadlineBadgeClass(tone),
)}
title="Deadline"
>
{formatDeadlineLabel(deadlineAt)}
</span>
{onMarkDone ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
disabled={busy}
title="Erledigt — Deadline entfernen"
onClick={e => {
e.stopPropagation();
onMarkDone();
}}
>
<Check className="size-3.5" aria-hidden />
<span className="sr-only">Deadline entfernen</span>
</Button>
) : null}
</div>
);
}
@@ -2,7 +2,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { Snippet } from "@/types/snippet"; import type { Snippet } from "@/types/snippet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { DateInput } from "@/components/ui/date-input";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useTagSuggestions } from "@/features/snippets/hooks/useTagSuggestions"; import { useTagSuggestions } from "@/features/snippets/hooks/useTagSuggestions";
import { TagPill } from "@/features/snippets/components/TagPill"; import { TagPill } from "@/features/snippets/components/TagPill";
@@ -20,6 +22,8 @@ type Props = {
tagsStr: string; tagsStr: string;
onBodyChange: (v: string) => void; onBodyChange: (v: string) => void;
onTagsStrChange: (v: string) => void; onTagsStrChange: (v: string) => void;
deadlineDate: string;
onDeadlineChange: (v: string) => void;
busy: boolean; busy: boolean;
error: string | null; error: string | null;
onSave: () => void; onSave: () => void;
@@ -36,6 +40,8 @@ export function SnippetEditDialog({
tagsStr, tagsStr,
onBodyChange, onBodyChange,
onTagsStrChange, onTagsStrChange,
deadlineDate,
onDeadlineChange,
busy, busy,
error, error,
onSave, onSave,
@@ -133,6 +139,24 @@ export function SnippetEditDialog({
<div className="dialog-snippet-preview snippet-md" dangerouslySetInnerHTML={{ __html: previewHtml }} /> <div className="dialog-snippet-preview snippet-md" dangerouslySetInnerHTML={{ __html: previewHtml }} />
) : null} ) : null}
</div> </div>
<div className="flex shrink-0 flex-wrap items-end gap-2">
<div className="min-w-[11rem] flex-1 space-y-1">
<Label htmlFor="snippet-deadline" className="text-xs text-muted-foreground">
Deadline (optional)
</Label>
<DateInput id="snippet-deadline" value={deadlineDate} onChange={e => onDeadlineChange(e.target.value)} />
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 shrink-0"
disabled={!deadlineDate}
onClick={() => onDeadlineChange("")}
>
Deadline entfernen
</Button>
</div>
{error ? <p className="shrink-0 text-sm text-destructive">{error}</p> : null} {error ? <p className="shrink-0 text-sm text-destructive">{error}</p> : null}
</div> </div>
+58
View File
@@ -292,4 +292,62 @@ body::before {
.tag-tree-loading { .tag-tree-loading {
@apply flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground; @apply flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground;
} }
/*
* Native <input type="date"> — Feld, WebKit-Segmente & Kalender-Icon.
* Das aufklappende Kalender-Popup gestaltet weiterhin der Browser.
*/
.date-field {
color-scheme: light;
}
.dark .date-field {
color-scheme: dark;
}
.date-field::-webkit-datetime-edit,
.date-field::-webkit-datetime-edit-fields-wrapper {
padding: 0.05rem 0;
}
.date-field::-webkit-datetime-edit-text {
color: var(--muted-foreground);
padding: 0 0.1rem;
}
.date-field::-webkit-datetime-edit-day-field,
.date-field::-webkit-datetime-edit-month-field,
.date-field::-webkit-datetime-edit-year-field {
padding: 0.1rem 0.2rem;
border-radius: 0.2rem;
}
.date-field:focus::-webkit-datetime-edit-day-field,
.date-field:focus::-webkit-datetime-edit-month-field,
.date-field:focus::-webkit-datetime-edit-year-field,
.date-field:focus-visible::-webkit-datetime-edit-day-field,
.date-field:focus-visible::-webkit-datetime-edit-month-field,
.date-field:focus-visible::-webkit-datetime-edit-year-field {
background-color: color-mix(in oklch, var(--accent) 45%, transparent);
color: var(--accent-foreground);
}
.date-field::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.65;
border-radius: 0.25rem;
padding: 0.15rem 0.35rem;
margin-inline-start: 0.125rem;
transition: opacity 0.12s ease, background-color 0.12s ease;
}
.date-field:hover::-webkit-calendar-picker-indicator {
opacity: 1;
background-color: color-mix(in oklch, var(--muted) 75%, transparent);
}
.date-field:focus::-webkit-calendar-picker-indicator,
.date-field:focus-visible::-webkit-calendar-picker-indicator {
opacity: 1;
}
} }
+55
View File
@@ -0,0 +1,55 @@
/** Kalendertag lokal als `YYYY-MM-DD` (für Vergleich mit DB). */
export function localDateKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** UI-Wert für `<input type="date" />` aus API-Wert. */
export function deadlineToDateInput(deadlineAt: string | null | undefined): string {
if (!deadlineAt) return "";
const s = deadlineAt.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
const t = new Date(s);
if (Number.isNaN(t.getTime())) return "";
return localDateKey(t);
}
export type DeadlineTone = "none" | "upcoming" | "today" | "overdue";
/** Blau = künftig (nicht heute), Orange = heute fällig, Rot = überschritten. */
export function deadlineTone(deadlineAt: string | null | undefined): DeadlineTone {
if (!deadlineAt) return "none";
const key = deadlineToDateInput(deadlineAt);
if (!key) return "none";
const today = localDateKey(new Date());
if (key < today) return "overdue";
if (key === today) return "today";
return "upcoming";
}
export function deadlineBadgeClass(tone: DeadlineTone): string {
switch (tone) {
case "upcoming":
return "border-blue-500/70 bg-blue-500/10 text-blue-800 dark:text-blue-200";
case "today":
return "border-orange-500/80 bg-orange-500/15 text-orange-900 dark:text-orange-100";
case "overdue":
return "border-destructive/80 bg-destructive/10 text-destructive";
default:
return "";
}
}
export function formatDeadlineLabel(deadlineAt: string): string {
const key = deadlineToDateInput(deadlineAt);
if (!key) return deadlineAt;
try {
const [y, m, d] = key.split("-").map(Number);
const dt = new Date(y, m - 1, d);
return dt.toLocaleDateString(undefined, { dateStyle: "medium" });
} catch {
return key;
}
}
+6
View File
@@ -44,6 +44,12 @@ function migrate(database: Database) {
} }
database.run(`CREATE INDEX IF NOT EXISTS idx_tag_tree_parent ON tag_tree_nodes(parent_id);`); database.run(`CREATE INDEX IF NOT EXISTS idx_tag_tree_parent ON tag_tree_nodes(parent_id);`);
database.run(`CREATE INDEX IF NOT EXISTS idx_snippets_updated ON snippets(updated_at);`); database.run(`CREATE INDEX IF NOT EXISTS idx_snippets_updated ON snippets(updated_at);`);
try {
database.run(`ALTER TABLE snippets ADD COLUMN deadline_at TEXT`);
} catch {
/* Spalte existiert bereits */
}
database.run(`CREATE INDEX IF NOT EXISTS idx_snippets_deadline ON snippets(deadline_at);`);
} }
export function getDb(): Database { export function getDb(): Database {
+27 -1
View File
@@ -1,5 +1,12 @@
import { readJson } from "@/server/http/readJson"; import { readJson } from "@/server/http/readJson";
import { createSnippet, deleteSnippet, getSnippetById, listSnippets, updateSnippet } from "@/server/snippets"; import {
createSnippet,
deleteSnippet,
getSnippetById,
listSnippets,
normalizeDeadlineAtInput,
updateSnippet,
} from "@/server/snippets";
export const snippetRoutes = { export const snippetRoutes = {
"/api/snippets": { "/api/snippets": {
@@ -16,11 +23,21 @@ export const snippetRoutes = {
} }
const o = body as Record<string, unknown>; const o = body as Record<string, unknown>;
const tags = Array.isArray(o.tags) ? o.tags.filter((t): t is string => typeof t === "string") : []; const tags = Array.isArray(o.tags) ? o.tags.filter((t): t is string => typeof t === "string") : [];
let deadline_at: string | null | undefined;
if ("deadline_at" in o) {
try {
deadline_at = normalizeDeadlineAtInput(o.deadline_at);
} catch (e) {
const msg = e instanceof Error ? e.message : "deadline_at ungültig";
return Response.json({ error: msg }, { status: 400 });
}
}
const snippet = createSnippet({ const snippet = createSnippet({
title: title:
o.title === null ? null : typeof o.title === "string" ? o.title : undefined, o.title === null ? null : typeof o.title === "string" ? o.title : undefined,
body: typeof o.body === "string" ? o.body : "", body: typeof o.body === "string" ? o.body : "",
tags, tags,
deadline_at,
}); });
return Response.json(snippet, { status: 201 }); return Response.json(snippet, { status: 201 });
}, },
@@ -53,6 +70,7 @@ export const snippetRoutes = {
body?: string; body?: string;
tags?: string[]; tags?: string[];
sort_order?: number; sort_order?: number;
deadline_at?: string | null;
} = {}; } = {};
if ("title" in o) { if ("title" in o) {
patch.title = o.title === null ? null : String(o.title); patch.title = o.title === null ? null : String(o.title);
@@ -66,6 +84,14 @@ export const snippetRoutes = {
if ("sort_order" in o && typeof o.sort_order === "number" && Number.isFinite(o.sort_order)) { if ("sort_order" in o && typeof o.sort_order === "number" && Number.isFinite(o.sort_order)) {
patch.sort_order = o.sort_order; patch.sort_order = o.sort_order;
} }
if ("deadline_at" in o) {
try {
patch.deadline_at = normalizeDeadlineAtInput(o.deadline_at);
} catch (e) {
const msg = e instanceof Error ? e.message : "deadline_at ungültig";
return Response.json({ error: msg }, { status: 400 });
}
}
const snippet = updateSnippet(id, patch); const snippet = updateSnippet(id, patch);
if (!snippet) { if (!snippet) {
return Response.json({ error: "Not found" }, { status: 404 }); return Response.json({ error: "Not found" }, { status: 404 });
+35 -9
View File
@@ -18,6 +18,7 @@ export type SnippetRow = {
sort_order: number; sort_order: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deadline_at: string | null;
tags: string[]; tags: string[];
/** Anzeige-Label pro Tag (Prefix + Name), gleiche Reihenfolge wie `tags`. */ /** Anzeige-Label pro Tag (Prefix + Name), gleiche Reihenfolge wie `tags`. */
tag_display: string[]; tag_display: string[];
@@ -25,6 +26,17 @@ export type SnippetRow = {
tag_colors: Record<string, string | null>; tag_colors: Record<string, string | null>;
}; };
/** PATCH/POST: `undefined` = nicht ändern, `null` = entfernen. */
export function normalizeDeadlineAtInput(raw: unknown): string | null | undefined {
if (raw === undefined) return undefined;
if (raw === null) return null;
if (typeof raw !== "string") throw new Error("deadline_at muss ein String (YYYY-MM-DD) sein");
const t = raw.trim();
if (t === "") return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) throw new Error("deadline_at muss YYYY-MM-DD sein");
return t;
}
/** `tag:x`, `title:y`, `content:z` (case-insensitive); remainder = default (title OR tag names, LIKE). All parts AND. */ /** `tag:x`, `title:y`, `content:z` (case-insensitive); remainder = default (title OR tag names, LIKE). All parts AND. */
function parseSearchQuery(raw: string): { function parseSearchQuery(raw: string): {
defaultText: string | null; defaultText: string | null;
@@ -69,6 +81,10 @@ function orderClause(sort: string): string {
return "ORDER BY s.title COLLATE NOCASE ASC NULLS LAST, s.updated_at DESC"; return "ORDER BY s.title COLLATE NOCASE ASC NULLS LAST, s.updated_at DESC";
case "title_desc": case "title_desc":
return "ORDER BY s.title COLLATE NOCASE DESC NULLS LAST, s.updated_at DESC"; return "ORDER BY s.title COLLATE NOCASE DESC NULLS LAST, s.updated_at DESC";
case "deadline_asc":
return "ORDER BY s.deadline_at ASC NULLS LAST, s.updated_at DESC";
case "deadline_desc":
return "ORDER BY s.deadline_at DESC NULLS LAST, s.updated_at DESC";
case "manual": case "manual":
return "ORDER BY s.sort_order ASC, s.updated_at DESC"; return "ORDER BY s.sort_order ASC, s.updated_at DESC";
case "updated_desc": case "updated_desc":
@@ -107,6 +123,7 @@ function rowToPublic(
sort_order: number; sort_order: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deadline_at: string | null;
}, },
tagsRaw: string[], tagsRaw: string[],
): SnippetRow { ): SnippetRow {
@@ -124,6 +141,7 @@ function rowToPublic(
sort_order: row.sort_order, sort_order: row.sort_order,
created_at: row.created_at, created_at: row.created_at,
updated_at: row.updated_at, updated_at: row.updated_at,
deadline_at: row.deadline_at,
tags, tags,
tag_display, tag_display,
tag_colors, tag_colors,
@@ -142,12 +160,13 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
sort_order: number; sort_order: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deadline_at: string | null;
}[]; }[];
if (trimmed === "") { if (trimmed === "") {
rows = database rows = database
.query( .query(
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at
FROM snippets s FROM snippets s
${order}`, ${order}`,
) )
@@ -229,7 +248,7 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
if (conditions.length === 0) { if (conditions.length === 0) {
rows = database rows = database
.query( .query(
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at
FROM snippets s FROM snippets s
${order}`, ${order}`,
) )
@@ -238,7 +257,7 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
const where = conditions.join(" AND "); const where = conditions.join(" AND ");
rows = database rows = database
.query( .query(
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at
FROM snippets s FROM snippets s
WHERE ${where} WHERE ${where}
${order}`, ${order}`,
@@ -256,7 +275,7 @@ export function getSnippetById(id: number): SnippetRow | null {
const database = getDb(); const database = getDb();
const row = database const row = database
.query( .query(
`SELECT id, title, body, sort_order, created_at, updated_at FROM snippets WHERE id = ?`, `SELECT id, title, body, sort_order, created_at, updated_at, deadline_at FROM snippets WHERE id = ?`,
) )
.get(id) as .get(id) as
| { | {
@@ -266,6 +285,7 @@ export function getSnippetById(id: number): SnippetRow | null {
sort_order: number; sort_order: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deadline_at: string | null;
} }
| undefined; | undefined;
if (!row) return null; if (!row) return null;
@@ -300,6 +320,7 @@ export function createSnippet(input: {
body?: string; body?: string;
tags?: string[]; tags?: string[];
sort_order?: number; sort_order?: number;
deadline_at?: string | null;
}): SnippetRow { }): SnippetRow {
const database = getDb(); const database = getDb();
const t = nowIso(); const t = nowIso();
@@ -311,10 +332,12 @@ export function createSnippet(input: {
: String(input.title).trim(); : String(input.title).trim();
const body = input.body ?? ""; const body = input.body ?? "";
const sortOrder = typeof input.sort_order === "number" ? input.sort_order : Date.now(); const sortOrder = typeof input.sort_order === "number" ? input.sort_order : Date.now();
const deadlineAt =
input.deadline_at === undefined ? null : input.deadline_at === null ? null : String(input.deadline_at).trim() || null;
database.run( database.run(
`INSERT INTO snippets (title, body, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, `INSERT INTO snippets (title, body, sort_order, created_at, updated_at, deadline_at) VALUES (?, ?, ?, ?, ?, ?)`,
[title, body, sortOrder, t, t], [title, body, sortOrder, t, t, deadlineAt],
); );
const idRow = database.query(`SELECT last_insert_rowid() AS id`).get() as { id: number }; const idRow = database.query(`SELECT last_insert_rowid() AS id`).get() as { id: number };
const newId = Number(idRow.id); const newId = Number(idRow.id);
@@ -329,6 +352,7 @@ export function updateSnippet(
body?: string; body?: string;
tags?: string[]; tags?: string[];
sort_order?: number; sort_order?: number;
deadline_at?: string | null;
}, },
): SnippetRow | null { ): SnippetRow | null {
const database = getDb(); const database = getDb();
@@ -336,11 +360,12 @@ export function updateSnippet(
if (!existing) return null; if (!existing) return null;
const current = database const current = database
.query(`SELECT title, body, sort_order FROM snippets WHERE id = ?`) .query(`SELECT title, body, sort_order, deadline_at FROM snippets WHERE id = ?`)
.get(id) as { .get(id) as {
title: string | null; title: string | null;
body: string; body: string;
sort_order: number; sort_order: number;
deadline_at: string | null;
}; };
const title = const title =
@@ -351,11 +376,12 @@ export function updateSnippet(
: String(input.title).trim(); : String(input.title).trim();
const body = input.body === undefined ? current.body : String(input.body); const body = input.body === undefined ? current.body : String(input.body);
const sort_order = input.sort_order === undefined ? current.sort_order : input.sort_order; const sort_order = input.sort_order === undefined ? current.sort_order : input.sort_order;
const deadline_at = input.deadline_at === undefined ? current.deadline_at : input.deadline_at;
const t = nowIso(); const t = nowIso();
database.run( database.run(
`UPDATE snippets SET title = ?, body = ?, sort_order = ?, updated_at = ? WHERE id = ?`, `UPDATE snippets SET title = ?, body = ?, sort_order = ?, deadline_at = ?, updated_at = ? WHERE id = ?`,
[title, body, sort_order, t, id], [title, body, sort_order, deadline_at, t, id],
); );
if (input.tags !== undefined) { if (input.tags !== undefined) {
+2
View File
@@ -5,6 +5,8 @@ export type Snippet = {
sort_order: number; sort_order: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
/** Kalendertag `YYYY-MM-DD` oder `null` = keine Deadline / erledigt. */
deadline_at: string | null;
tags: string[]; tags: string[];
/** Anzeige pro Tag (Prefix + Name für Baum-Tags), gleiche Reihenfolge wie `tags`. */ /** Anzeige pro Tag (Prefix + Name für Baum-Tags), gleiche Reihenfolge wie `tags`. */
tag_display: string[]; tag_display: string[];