snapshot
This commit is contained in:
@@ -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 };
|
||||
@@ -1,6 +1,8 @@
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: "updated_desc", label: "Zuletzt geändert (neu 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_desc", label: "Titel Z–A" },
|
||||
] as const;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Snippet } from "@/types/snippet";
|
||||
import { deadlineToDateInput } from "@/lib/snippetDeadline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { SnippetDeadlineBadge } from "@/features/snippets/components/SnippetDeadlineBadge";
|
||||
import { SnippetEditDialog } from "@/features/snippets/components/SnippetEditDialog";
|
||||
import { TagPill } from "@/features/snippets/components/TagPill";
|
||||
import { colorForTagName, tagsFromString } from "@/features/snippets/lib/tagFieldUtils";
|
||||
@@ -18,12 +20,14 @@ type 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 [dirty, setDirty] = useState(false);
|
||||
const [body, setBody] = useState(() => snippet.body ?? "");
|
||||
const [tagsStr, setTagsStr] = useState(() => (snippet.tags ?? []).join(", "));
|
||||
const [deadlineDate, setDeadlineDate] = useState(() => deadlineToDateInput(snippet.deadline_at));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -33,11 +37,13 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
|
||||
if (dialogOpen) return;
|
||||
setBody(snippet.body ?? "");
|
||||
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 = () => {
|
||||
setBody(snippet.body ?? "");
|
||||
setTagsStr((snippet.tags ?? []).join(", "));
|
||||
setDeadlineDate(deadlineToDateInput(snippet.deadline_at));
|
||||
setDirty(false);
|
||||
setError(null);
|
||||
};
|
||||
@@ -68,6 +74,7 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
|
||||
title: null,
|
||||
body,
|
||||
tags: tagsFromString(tagsStr),
|
||||
deadline_at: deadlineDate.trim() === "" ? null : deadlineDate.trim(),
|
||||
}),
|
||||
});
|
||||
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) {
|
||||
if (askConfirm && !window.confirm("Snippet wirklich löschen?")) return;
|
||||
setBusy(true);
|
||||
@@ -108,7 +138,7 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -125,8 +155,17 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
|
||||
<div className="snippet-md overflow-auto" dangerouslySetInnerHTML={{ __html: viewHtml }} />
|
||||
</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>
|
||||
<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">
|
||||
{snippet.tags.length ? (
|
||||
snippet.tags.map((t, i) => (
|
||||
@@ -192,6 +231,11 @@ export function SnippetCard({ snippet, treeColorByName, treeDisplayByName, onRep
|
||||
markDirty();
|
||||
setTagsStr(v);
|
||||
}}
|
||||
deadlineDate={deadlineDate}
|
||||
onDeadlineChange={v => {
|
||||
markDirty();
|
||||
setDeadlineDate(v);
|
||||
}}
|
||||
busy={busy}
|
||||
error={error}
|
||||
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 { Button } from "@/components/ui/button";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTagSuggestions } from "@/features/snippets/hooks/useTagSuggestions";
|
||||
import { TagPill } from "@/features/snippets/components/TagPill";
|
||||
@@ -20,6 +22,8 @@ type Props = {
|
||||
tagsStr: string;
|
||||
onBodyChange: (v: string) => void;
|
||||
onTagsStrChange: (v: string) => void;
|
||||
deadlineDate: string;
|
||||
onDeadlineChange: (v: string) => void;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onSave: () => void;
|
||||
@@ -36,6 +40,8 @@ export function SnippetEditDialog({
|
||||
tagsStr,
|
||||
onBodyChange,
|
||||
onTagsStrChange,
|
||||
deadlineDate,
|
||||
onDeadlineChange,
|
||||
busy,
|
||||
error,
|
||||
onSave,
|
||||
@@ -133,6 +139,24 @@ export function SnippetEditDialog({
|
||||
<div className="dialog-snippet-preview snippet-md" dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
||||
) : null}
|
||||
</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}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -292,4 +292,62 @@ body::before {
|
||||
.tag-tree-loading {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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_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 {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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 = {
|
||||
"/api/snippets": {
|
||||
@@ -16,11 +23,21 @@ export const snippetRoutes = {
|
||||
}
|
||||
const o = body as Record<string, unknown>;
|
||||
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({
|
||||
title:
|
||||
o.title === null ? null : typeof o.title === "string" ? o.title : undefined,
|
||||
body: typeof o.body === "string" ? o.body : "",
|
||||
tags,
|
||||
deadline_at,
|
||||
});
|
||||
return Response.json(snippet, { status: 201 });
|
||||
},
|
||||
@@ -53,6 +70,7 @@ export const snippetRoutes = {
|
||||
body?: string;
|
||||
tags?: string[];
|
||||
sort_order?: number;
|
||||
deadline_at?: string | null;
|
||||
} = {};
|
||||
if ("title" in o) {
|
||||
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)) {
|
||||
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);
|
||||
if (!snippet) {
|
||||
return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
+35
-9
@@ -18,6 +18,7 @@ export type SnippetRow = {
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deadline_at: string | null;
|
||||
tags: string[];
|
||||
/** Anzeige-Label pro Tag (Prefix + Name), gleiche Reihenfolge wie `tags`. */
|
||||
tag_display: string[];
|
||||
@@ -25,6 +26,17 @@ export type SnippetRow = {
|
||||
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. */
|
||||
function parseSearchQuery(raw: string): {
|
||||
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";
|
||||
case "title_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":
|
||||
return "ORDER BY s.sort_order ASC, s.updated_at DESC";
|
||||
case "updated_desc":
|
||||
@@ -107,6 +123,7 @@ function rowToPublic(
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deadline_at: string | null;
|
||||
},
|
||||
tagsRaw: string[],
|
||||
): SnippetRow {
|
||||
@@ -124,6 +141,7 @@ function rowToPublic(
|
||||
sort_order: row.sort_order,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
deadline_at: row.deadline_at,
|
||||
tags,
|
||||
tag_display,
|
||||
tag_colors,
|
||||
@@ -142,12 +160,13 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deadline_at: string | null;
|
||||
}[];
|
||||
|
||||
if (trimmed === "") {
|
||||
rows = database
|
||||
.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
|
||||
${order}`,
|
||||
)
|
||||
@@ -229,7 +248,7 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
|
||||
if (conditions.length === 0) {
|
||||
rows = database
|
||||
.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
|
||||
${order}`,
|
||||
)
|
||||
@@ -238,7 +257,7 @@ export function listSnippets(q: string | null, sort: string | null): SnippetRow[
|
||||
const where = conditions.join(" AND ");
|
||||
rows = database
|
||||
.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
|
||||
WHERE ${where}
|
||||
${order}`,
|
||||
@@ -256,7 +275,7 @@ export function getSnippetById(id: number): SnippetRow | null {
|
||||
const database = getDb();
|
||||
const row = database
|
||||
.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
|
||||
| {
|
||||
@@ -266,6 +285,7 @@ export function getSnippetById(id: number): SnippetRow | null {
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deadline_at: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
@@ -300,6 +320,7 @@ export function createSnippet(input: {
|
||||
body?: string;
|
||||
tags?: string[];
|
||||
sort_order?: number;
|
||||
deadline_at?: string | null;
|
||||
}): SnippetRow {
|
||||
const database = getDb();
|
||||
const t = nowIso();
|
||||
@@ -311,10 +332,12 @@ export function createSnippet(input: {
|
||||
: String(input.title).trim();
|
||||
const body = input.body ?? "";
|
||||
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(
|
||||
`INSERT INTO snippets (title, body, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
[title, body, sortOrder, t, t],
|
||||
`INSERT INTO snippets (title, body, sort_order, created_at, updated_at, deadline_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[title, body, sortOrder, t, t, deadlineAt],
|
||||
);
|
||||
const idRow = database.query(`SELECT last_insert_rowid() AS id`).get() as { id: number };
|
||||
const newId = Number(idRow.id);
|
||||
@@ -329,6 +352,7 @@ export function updateSnippet(
|
||||
body?: string;
|
||||
tags?: string[];
|
||||
sort_order?: number;
|
||||
deadline_at?: string | null;
|
||||
},
|
||||
): SnippetRow | null {
|
||||
const database = getDb();
|
||||
@@ -336,11 +360,12 @@ export function updateSnippet(
|
||||
if (!existing) return null;
|
||||
|
||||
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 {
|
||||
title: string | null;
|
||||
body: string;
|
||||
sort_order: number;
|
||||
deadline_at: string | null;
|
||||
};
|
||||
|
||||
const title =
|
||||
@@ -351,11 +376,12 @@ export function updateSnippet(
|
||||
: String(input.title).trim();
|
||||
const body = input.body === undefined ? current.body : String(input.body);
|
||||
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();
|
||||
|
||||
database.run(
|
||||
`UPDATE snippets SET title = ?, body = ?, sort_order = ?, updated_at = ? WHERE id = ?`,
|
||||
[title, body, sort_order, t, id],
|
||||
`UPDATE snippets SET title = ?, body = ?, sort_order = ?, deadline_at = ?, updated_at = ? WHERE id = ?`,
|
||||
[title, body, sort_order, deadline_at, t, id],
|
||||
);
|
||||
|
||||
if (input.tags !== undefined) {
|
||||
|
||||
@@ -5,6 +5,8 @@ export type Snippet = {
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** Kalendertag `YYYY-MM-DD` oder `null` = keine Deadline / erledigt. */
|
||||
deadline_at: string | null;
|
||||
tags: string[];
|
||||
/** Anzeige pro Tag (Prefix + Name für Baum-Tags), gleiche Reihenfolge wie `tags`. */
|
||||
tag_display: string[];
|
||||
|
||||
Reference in New Issue
Block a user