feat: p2 batch 28-31 reminders lists comments bang stats
Calendar events support multiple reminder offsets with presets and per-user defaults. Lists index adds inline task entry and list property editing. Generic entity comments on list detail. New bangs.stats dashboard widget. Closes Gitea #28, #29, #30, #31. Migrations 0022 and 0023.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquare, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { addComment, deleteComment, listComments, type CommentDto } from "@/modules/_core/comments";
|
||||
|
||||
type Props = {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
currentUserId: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function EntityComments({ entityType, entityId, currentUserId, compact = false }: Props) {
|
||||
const [open, setOpen] = useState(!compact);
|
||||
const [comments, setComments] = useState<CommentDto[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
listComments(entityType, entityId)
|
||||
.then(setComments)
|
||||
.catch(() => setComments([]));
|
||||
}, [entityType, entityId, open]);
|
||||
|
||||
function submitComment() {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
startTransition(async () => {
|
||||
const created = await addComment({ entityType, entityId, body });
|
||||
setComments((current) => [...current, created]);
|
||||
setDraft("");
|
||||
});
|
||||
}
|
||||
|
||||
function removeComment(id: string) {
|
||||
startTransition(async () => {
|
||||
await deleteComment({ id });
|
||||
setComments((current) => current.filter((comment) => comment.id !== id));
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? "mt-1" : "mt-3 border-t border-[var(--hair)] pt-3"}>
|
||||
{compact ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[12px] text-[var(--ink-mute)] hover:text-[var(--ink)]"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<MessageSquare className="size-3.5" />
|
||||
{open ? "Hide comments" : "Comments"}
|
||||
{comments.length > 0 ? ` (${comments.length})` : ""}
|
||||
</button>
|
||||
) : (
|
||||
<div className="eyebrow mb-2">Comments</div>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-[12px] text-[var(--ink-mute)]">No comments yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{comments.map((comment) => (
|
||||
<li
|
||||
key={comment.id}
|
||||
className="rounded-md border border-[var(--hair)] px-2.5 py-2 text-[13px]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] text-[var(--ink-mute)]">
|
||||
{comment.authorName ?? "Someone"} · {formatCommentTime(comment.createdAt)}
|
||||
</div>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
</div>
|
||||
{comment.authorId === currentUserId ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Delete comment"
|
||||
disabled={isPending}
|
||||
onClick={() => removeComment(comment.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Add a comment…"
|
||||
disabled={isPending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitComment();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!draft.trim() || isPending}
|
||||
onClick={submitComment}
|
||||
>
|
||||
Post
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCommentTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { ReminderPicker } from "@/components/reminder-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { setDefaultEventReminderOffsets } from "@/app/settings/reminder-actions";
|
||||
|
||||
export function DefaultEventRemindersSetting({ initialOffsets }: { initialOffsets: number[] }) {
|
||||
const [saved, setSaved] = useState(initialOffsets);
|
||||
const [offsets, setOffsets] = useState(initialOffsets);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const dirty =
|
||||
offsets.length !== saved.length || offsets.some((value, index) => value !== saved[index]);
|
||||
|
||||
function handleSave() {
|
||||
startTransition(async () => {
|
||||
await setDefaultEventReminderOffsets(offsets);
|
||||
setSaved(offsets);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="muted text-[13px]">
|
||||
Applied when you create a new calendar event. You can still customize reminders per event.
|
||||
</p>
|
||||
<ReminderPicker offsets={offsets} onChange={setOffsets} disabled={isPending} />
|
||||
<Button type="button" size="sm" disabled={!dirty || isPending} onClick={handleSave}>
|
||||
{isPending ? "Saving…" : "Save defaults"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { ReminderPicker } from "@/components/reminder-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,7 +23,11 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { createEvent } from "@/modules/calendar/server/actions";
|
||||
import { richTextToPlainText } from "@/components/rich-text";
|
||||
import { listCalendars, type CalendarDto } from "@/modules/calendar/server/queries";
|
||||
import {
|
||||
getDefaultEventReminderOffsets,
|
||||
listCalendars,
|
||||
type CalendarDto,
|
||||
} from "@/modules/calendar/server/queries";
|
||||
|
||||
const RichTextEditor = dynamic(
|
||||
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
|
||||
@@ -59,7 +64,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
||||
const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000)));
|
||||
const [location, setLocation] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [remind, setRemind] = useState(true);
|
||||
const [reminderOffsets, setReminderOffsets] = useState<number[]>([30]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,6 +72,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
||||
setCalendars(rows);
|
||||
setCalendarId(rows[0]?.id ?? "");
|
||||
});
|
||||
getDefaultEventReminderOffsets().then(setReminderOffsets);
|
||||
}, []);
|
||||
|
||||
const calendarItems = useMemo(
|
||||
@@ -86,7 +92,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
||||
allDay: false,
|
||||
location: location || null,
|
||||
notes: richTextToPlainText(notes) ? notes : null,
|
||||
remindMinutesBefore: remind ? 30 : null,
|
||||
reminderOffsets,
|
||||
});
|
||||
onDone();
|
||||
});
|
||||
@@ -171,15 +177,11 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
|
||||
placeholder="Add details…"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 cursor-pointer"
|
||||
checked={remind}
|
||||
onChange={(e) => setRemind(e.target.checked)}
|
||||
/>
|
||||
Remind me 30 min before
|
||||
</label>
|
||||
<ReminderPicker
|
||||
offsets={reminderOffsets}
|
||||
onChange={setReminderOffsets}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button type="submit" form="quick-add-event-form" disabled={!title.trim() || isPending}>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
REMINDER_PRESETS,
|
||||
customOffsetToMinutes,
|
||||
formatReminderOffset,
|
||||
normalizeReminderOffsets,
|
||||
type ReminderOffsetUnit,
|
||||
} from "@/lib/reminder-offsets";
|
||||
|
||||
type Props = {
|
||||
offsets: number[];
|
||||
onChange: (offsets: number[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ReminderPicker({ offsets, onChange, disabled }: Props) {
|
||||
const [customValue, setCustomValue] = useState("1");
|
||||
const [customUnit, setCustomUnit] = useState<ReminderOffsetUnit>("hours");
|
||||
const normalized = useMemo(() => normalizeReminderOffsets(offsets), [offsets]);
|
||||
|
||||
function addOffset(minutes: number) {
|
||||
onChange(normalizeReminderOffsets([...normalized, minutes]));
|
||||
}
|
||||
|
||||
function removeOffset(minutes: number) {
|
||||
onChange(normalized.filter((o) => o !== minutes));
|
||||
}
|
||||
|
||||
function addCustom() {
|
||||
const parsed = Number.parseInt(customValue, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return;
|
||||
addOffset(customOffsetToMinutes(parsed, customUnit));
|
||||
}
|
||||
|
||||
const availablePresets = REMINDER_PRESETS.filter((p) => !normalized.includes(p.offsetMinutes));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label>Reminders</Label>
|
||||
{normalized.length === 0 ? (
|
||||
<p className="text-[13px] text-[var(--ink-mute)]">No reminders set.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{normalized.map((offset) => (
|
||||
<li
|
||||
key={offset}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hair)] px-2.5 py-1.5 text-[13px]"
|
||||
>
|
||||
<span>{formatReminderOffset(offset)}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Remove ${formatReminderOffset(offset)}`}
|
||||
disabled={disabled}
|
||||
onClick={() => removeOffset(offset)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{availablePresets.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{availablePresets.map((preset) => (
|
||||
<Button
|
||||
key={preset.offsetMinutes}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={() => addOffset(preset.offsetMinutes)}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-[80px_120px_auto] sm:items-end">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="reminder-custom-value">Custom</Label>
|
||||
<Input
|
||||
id="reminder-custom-value"
|
||||
type="number"
|
||||
min={0}
|
||||
value={customValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="reminder-custom-unit">Unit</Label>
|
||||
<Select
|
||||
value={customUnit}
|
||||
onValueChange={(value) => setCustomUnit(value as ReminderOffsetUnit)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger id="reminder-custom-unit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="minutes">Minutes</SelectItem>
|
||||
<SelectItem value="hours">Hours</SelectItem>
|
||||
<SelectItem value="days">Days</SelectItem>
|
||||
<SelectItem value="weeks">Weeks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" disabled={disabled} onClick={addCustom}>
|
||||
Add custom
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user