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 = [
|
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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 {
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user