Prepare production and Coolify deployment with original exports and datetime pickers
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export function GET() {
|
||||
return Response.json({ status: "ok" });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import { api } from "@/trpc/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function EventExports({eventId}:{eventId:string}) {
|
||||
const [filter,setFilter] = useState<"approved"|"all">("approved");
|
||||
const history = api.manager.exports.useQuery({eventId},{refetchInterval:3000});
|
||||
const request = api.manager.requestExport.useMutation({onSuccess:()=>{toast.success("Originals export queued");void history.refetch();},onError:e=>toast.error(e.message)});
|
||||
const download = api.manager.downloadExport.useMutation({onSuccess:result=>{window.location.assign(result.url);},onError:e=>toast.error(e.message)});
|
||||
return <section className="flex flex-col gap-3" aria-label="Export originals">
|
||||
<h2 className="text-lg font-semibold">Export originals</h2>
|
||||
<p className="text-sm text-muted-foreground">Full-quality files, unchanged—including original metadata. ZIPs expire after 24 hours. Includes processed gallery photos, not standalone banners. Maximum 10,000 photos / 20 GB per export.</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={filter} onValueChange={value=>setFilter(value as "approved"|"all")}>
|
||||
<SelectTrigger aria-label="Photos to export"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectGroup><SelectItem value="approved">Approved photos</SelectItem><SelectItem value="all">All photos, including private</SelectItem></SelectGroup></SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" disabled={request.isPending || history.data?.some(job=>job.status==="pending"||job.status==="processing")} onClick={()=>request.mutate({eventId,filter})}><Download data-icon="inline-start" />Export originals</Button>
|
||||
</div>
|
||||
{history.isError ? <p role="alert">Could not load exports.</p> : null}
|
||||
<ul className="flex flex-col gap-2" aria-live="polite">{history.data?.map(job=><li key={job.id} className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm">{job.filter==="all"?"All photos":"Approved photos"} · {job.status.charAt(0).toUpperCase()+job.status.slice(1)}{job.total>0?` · ${job.processed}/${job.total}`:""}{job.status==="failed"?" — empty album, size limit, or processing error; try a new export.":""}</span>
|
||||
{job.status==="ready" ? <Button variant="outline" disabled={download.isPending} onClick={()=>download.mutate({eventId,exportId:job.id})}><Download data-icon="inline-start" />Download ZIP</Button> : null}
|
||||
</li>)}</ul>
|
||||
</section>;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/trpc/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
|
||||
@@ -36,7 +36,7 @@ export function EventSchedule({ eventId }: { eventId: string }) {
|
||||
}}>
|
||||
<FieldGroup className="grid gap-4 sm:grid-cols-2">
|
||||
{dateFields.map(([key, label]) => <Field key={key}><FieldLabel htmlFor={`schedule-${key}`}>{label}</FieldLabel>
|
||||
<Input id={`schedule-${key}`} type="datetime-local" value={dates[key] ?? localDate(event.data![key])} onChange={(e) => setDates({ ...dates, [key]: e.target.value })} />
|
||||
<DateTimePicker id={`schedule-${key}`} ariaLabel={label} disabled={update.isPending} value={dates[key] ?? localDate(event.data![key])} onValueChange={(value) => setDates(previous => ({ ...previous, [key]: value }))} />
|
||||
</Field>)}
|
||||
</FieldGroup>
|
||||
<p className="text-sm text-muted-foreground">Setting a future public date hides the guest page until then. Gallery and note schedules never approve existing private content. Submission closing ends new contributions; it does not hide the gallery. Dates alone do not send email.</p>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { EventAudit } from "./event-audit";
|
||||
import { EventSchedule } from "./event-schedule";
|
||||
import { eventStatusLabel } from "@/lib/event-status";
|
||||
import { effectiveEvent } from "@/lib/event-lifecycle";
|
||||
import { EventExports } from "./event-exports";
|
||||
|
||||
export default async function EventDashboardPage({
|
||||
params,
|
||||
@@ -37,12 +38,15 @@ export default async function EventDashboardPage({
|
||||
id: "photos",
|
||||
label: "Photos",
|
||||
content: (
|
||||
<div className="flex flex-col gap-6">
|
||||
{canSettings && event.permissions.includes("photos.private.read") ? <EventExports eventId={event.id} /> : null}
|
||||
<ModerationGrid
|
||||
eventId={event.id}
|
||||
canModerate={canModerate}
|
||||
canDelete={event.permissions.includes("photos.delete")}
|
||||
canPrivate={event.permissions.includes("photos.private.read")}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(canSettings
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
}) {
|
||||
const defaults = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
captionLayout={captionLayout}
|
||||
className={cn(
|
||||
"group/calendar p-3",
|
||||
className,
|
||||
)}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaults.root),
|
||||
months: cn("relative flex flex-col gap-4 md:flex-row", defaults.months),
|
||||
month: cn("flex w-[15.75rem] flex-col gap-4", defaults.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex items-center justify-between",
|
||||
defaults.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-9 p-0",
|
||||
defaults.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-9 p-0",
|
||||
defaults.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-9 items-center justify-center px-9",
|
||||
defaults.month_caption,
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none text-sm font-bold",
|
||||
captionLayout !== "label" &&
|
||||
"flex h-8 items-center gap-1 rounded-md pl-2 pr-1",
|
||||
defaults.caption_label,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-9 items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaults.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative rounded-md border border-input focus-within:ring-2 focus-within:ring-ring",
|
||||
defaults.dropdown_root,
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute inset-0 bg-popover opacity-0",
|
||||
defaults.dropdown,
|
||||
),
|
||||
month_grid: cn("w-full border-collapse", defaults.month_grid),
|
||||
weekdays: cn("grid grid-cols-7", defaults.weekdays),
|
||||
weekday: cn(
|
||||
"flex size-9 select-none items-center justify-center rounded-md text-center text-xs font-semibold text-muted-foreground",
|
||||
defaults.weekday,
|
||||
),
|
||||
week: cn("mt-2 grid grid-cols-7", defaults.week),
|
||||
day: cn(
|
||||
"relative size-9 p-0 text-center",
|
||||
defaults.day,
|
||||
),
|
||||
today: cn(
|
||||
"rounded-md bg-accent text-accent-foreground",
|
||||
defaults.today,
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaults.outside,
|
||||
),
|
||||
disabled: cn(
|
||||
"pointer-events-none text-muted-foreground opacity-40",
|
||||
defaults.disabled,
|
||||
),
|
||||
hidden: cn("invisible", defaults.hidden),
|
||||
range_start: cn("rounded-l-md bg-accent", defaults.range_start),
|
||||
range_middle: cn("rounded-none bg-accent", defaults.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaults.range_end),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ className: iconClassName, orientation, ...iconProps }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon
|
||||
className={cn("size-4", iconClassName)}
|
||||
{...iconProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", iconClassName)}
|
||||
{...iconProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ChevronDownIcon
|
||||
className={cn("size-4", iconClassName)}
|
||||
{...iconProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon" }),
|
||||
"size-9 rounded-md p-0 font-medium data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CalendarIcon, X } from "lucide-react";
|
||||
import { format, isValid, parse } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function parseFormDate(value?: string) {
|
||||
if (!value) return undefined;
|
||||
const date = parse(value, "yyyy-MM-dd", new Date());
|
||||
return isValid(date) ? date : undefined;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
name,
|
||||
id,
|
||||
defaultValue,
|
||||
value: controlledValue,
|
||||
onValueChange,
|
||||
placeholder = "Pick a date",
|
||||
"aria-label": ariaLabel = "Select date",
|
||||
disabled,
|
||||
clearable = true,
|
||||
className,
|
||||
}: {
|
||||
name?: string;
|
||||
id?: string;
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
"aria-label"?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const value = controlledValue ?? internalValue;
|
||||
const date = parseFormDate(value);
|
||||
|
||||
function update(next: Date | undefined) {
|
||||
const nextValue = next ? format(next, "yyyy-MM-dd") : "";
|
||||
if (controlledValue === undefined) setInternalValue(nextValue);
|
||||
onValueChange?.(nextValue);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-w-0 gap-1", className)}>
|
||||
{name ? <input type="hidden" name={name} value={value} /> : null}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
id={id}
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
data-empty={!date}
|
||||
className="min-w-0 flex-1 justify-start px-3 text-left font-medium data-[empty=true]:text-muted-foreground"
|
||||
>
|
||||
<CalendarIcon data-icon="inline-start" />
|
||||
<span className="truncate">
|
||||
{date ? format(date, "PPP") : placeholder}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={update}
|
||||
defaultMonth={date}
|
||||
autoFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{clearable && value ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={`Clear ${ariaLabel.toLowerCase()}`}
|
||||
onClick={() => update(undefined)}
|
||||
>
|
||||
<X data-icon="inline-start" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { format, isValid, parse } from "date-fns";
|
||||
import { CalendarIcon, ChevronDown } from "lucide-react";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { TimeFields, formatTimeValue } from "@/components/ui/time-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
function parseDate(value: string) {
|
||||
const date = parse(value, "yyyy-MM-dd", new Date(2000, 0, 1));
|
||||
return isValid(date) ? date : undefined;
|
||||
}
|
||||
|
||||
// One popup with a draft: dismissing never modifies the controlled field.
|
||||
export function DateTimePicker({id,value,onValueChange,disabled,ariaLabel="Date and time",className}:{
|
||||
id:string;
|
||||
value:string;
|
||||
onValueChange:(value:string)=>void;
|
||||
disabled?:boolean;
|
||||
ariaLabel?:string;
|
||||
className?:string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draftDate, setDraftDate] = useState("");
|
||||
const [draftTime, setDraftTime] = useState("19:00");
|
||||
const selected = parseDate(value.slice(0,10));
|
||||
const draft = parseDate(draftDate);
|
||||
return <Popover open={open} onOpenChange={next => {
|
||||
if (next) { setDraftDate(value.slice(0,10)); setDraftTime(value.slice(11,16)||"19:00"); }
|
||||
setOpen(next);
|
||||
}}>
|
||||
<div className={cn("flex min-w-0 items-center gap-2", className)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button id={id} type="button" variant="outline" disabled={disabled} aria-label={ariaLabel} className="min-w-0 flex-1 justify-between">
|
||||
<CalendarIcon data-icon="inline-start" />
|
||||
<span className="flex-1 truncate text-left">{selected ? `${format(selected,"PP")} · ${formatTimeValue(value.slice(11,16))}` : "Pick date & time"}</span>
|
||||
<ChevronDown data-icon="inline-end" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<Button type="button" variant="ghost" disabled={disabled || !value} aria-label={`Clear ${ariaLabel.toLowerCase()}`} onClick={() => { onValueChange(""); setOpen(false); }}>Clear</Button>
|
||||
</div>
|
||||
<PopoverContent align="start" collisionPadding={12} aria-label={`${ariaLabel}: choose date and time`} className="flex w-[18rem] max-w-[calc(100vw-2rem)] flex-col gap-3 overflow-y-auto p-3 max-h-[var(--radix-popover-content-available-height)]">
|
||||
<Calendar mode="single" selected={draft} defaultMonth={draft} onSelect={date=>setDraftDate(date?format(date,"yyyy-MM-dd"):"")} className="self-center p-0" classNames={{ month: "flex w-[15.75rem] flex-col gap-1", week: "grid grid-cols-7" }} />
|
||||
<Separator />
|
||||
<TimeFields id={`${id}-time`} value={draftTime} onValueChange={setDraftTime} />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">Device timezone</p>
|
||||
<Button type="button" disabled={!draft} onClick={()=>{onValueChange(`${draftDate}T${draftTime}`);setOpen(false);}}>Apply</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-lg border bg-card p-4 text-card-foreground shadow-xl outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {expect,test} from "bun:test";
|
||||
import {formatTimeValue,parseTimeValue,toTimeValue} from "./time-picker";
|
||||
|
||||
test("RaceTix time picker preserves midnight, noon and non-five-minute values",()=>{
|
||||
expect(formatTimeValue("00:00")).toBe("12:00 AM");
|
||||
expect(formatTimeValue("12:00")).toBe("12:00 PM");
|
||||
expect(formatTimeValue("23:57")).toBe("11:57 PM");
|
||||
for(let h=0;h<24;h++) for(const m of ["00","07","30","59"]) {
|
||||
const value=`${String(h).padStart(2,"0")}:${m}`;
|
||||
const parsed=parseTimeValue(value);
|
||||
expect(toTimeValue(parsed.hour,parsed.minute,parsed.period)).toBe(value);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Clock3, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
type SelectFieldOption = {value:string;label:string};
|
||||
function SelectField({id,ariaLabel,value,options,onValueChange}:{id:string;ariaLabel:string;value:string;options:SelectFieldOption[];onValueChange:(value:string)=>void}) {
|
||||
return <Select value={value} onValueChange={onValueChange}><SelectTrigger id={id} aria-label={ariaLabel} className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{options.map(option=><SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectGroup></SelectContent></Select>;
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Period = "AM" | "PM";
|
||||
|
||||
export function parseTimeValue(value: string) {
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(value);
|
||||
if (!match) return { hour: "7", minute: "00", period: "PM" as Period };
|
||||
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (hours > 23 || minutes > 59) {
|
||||
return { hour: "7", minute: "00", period: "PM" as Period };
|
||||
}
|
||||
|
||||
return {
|
||||
hour: String(hours % 12 || 12),
|
||||
minute: String(minutes).padStart(2, "0"),
|
||||
period: (hours >= 12 ? "PM" : "AM") as Period,
|
||||
};
|
||||
}
|
||||
|
||||
export function toTimeValue(hour: string, minute: string, period: Period) {
|
||||
const hourNumber = Number(hour) % 12;
|
||||
const hours = period === "PM" ? hourNumber + 12 : hourNumber;
|
||||
return `${String(hours).padStart(2, "0")}:${minute.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatTimeValue(value: string) {
|
||||
const { hour, minute, period } = parseTimeValue(value);
|
||||
return `${hour}:${minute} ${period}`;
|
||||
}
|
||||
|
||||
const hourOptions = Array.from({ length: 12 }, (_, index) => ({ value: String(index + 1), label: String(index + 1) }));
|
||||
const standardMinutes = Array.from({ length: 12 }, (_, index) => String(index * 5).padStart(2, "0"));
|
||||
|
||||
export function TimeFields({ id, value, onValueChange }: {
|
||||
id: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
}) {
|
||||
const { hour, minute, period } = parseTimeValue(value);
|
||||
const minutes = standardMinutes.includes(minute) ? standardMinutes : [...standardMinutes, minute].sort();
|
||||
return (
|
||||
<FieldGroup className="flex-row gap-2">
|
||||
<Field className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor={`${id}-hour`}>Hour</FieldLabel>
|
||||
<SelectField id={`${id}-hour`} ariaLabel="Hour" value={hour} options={hourOptions} onValueChange={next => onValueChange(toTimeValue(next, minute, period))} />
|
||||
</Field>
|
||||
<Field className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor={`${id}-minute`}>Minute</FieldLabel>
|
||||
<SelectField id={`${id}-minute`} ariaLabel="Minute" value={minute} options={minutes.map(value => ({ value, label: value }))} onValueChange={next => onValueChange(toTimeValue(hour, next, period))} />
|
||||
</Field>
|
||||
<Field className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor={`${id}-period`}>Period</FieldLabel>
|
||||
<SelectField id={`${id}-period`} ariaLabel="AM or PM" value={period} options={[{ value: "AM", label: "AM" }, { value: "PM", label: "PM" }]} onValueChange={next => onValueChange(toTimeValue(hour, minute, next as Period))} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimePicker({ id, name, value, defaultValue = "19:00", onValueChange, disabled, required, ariaLabel = "Time", className }: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const generatedId = React.useId();
|
||||
const pickerId = id ?? generatedId;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [internalValue, setInternalValue] = React.useState(defaultValue);
|
||||
const isControlled = value !== undefined;
|
||||
const currentValue = value ?? internalValue;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{name ? <input type="hidden" name={name} value={currentValue} /> : null}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
id={pickerId}
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-required={required}
|
||||
className={cn(
|
||||
"min-w-[8.75rem] justify-between gap-2 px-3 font-semibold",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Clock3 data-icon="inline-start" />
|
||||
{formatTimeValue(currentValue)}
|
||||
</span>
|
||||
<ChevronDown data-icon="inline-start" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="flex w-[18rem] max-w-[calc(100vw-2rem)] flex-col gap-4 p-4">
|
||||
<div>
|
||||
<p className="text-sm font-bold">Choose a time</p>
|
||||
<p className="text-xs text-muted-foreground">Times use your device’s timezone.</p>
|
||||
</div>
|
||||
<TimeFields id={pickerId} value={currentValue} onValueChange={next => { if (!isControlled) setInternalValue(next); onValueChange?.(next); }} />
|
||||
<Button type="button" className="w-full" onClick={() => setOpen(false)}>
|
||||
Done
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -16,11 +16,13 @@ import {
|
||||
groupMemberships,
|
||||
guests,
|
||||
photos,
|
||||
photoExports,
|
||||
submissions,
|
||||
user,
|
||||
} from "@album/database";
|
||||
import {
|
||||
createEventInputSchema,
|
||||
exportPhotosInputSchema,
|
||||
checkEventSlugInputSchema,
|
||||
generateEventSlugInputSchema,
|
||||
locationSearchInputSchema,
|
||||
@@ -79,6 +81,30 @@ async function signedPhotoUrls(photo: {
|
||||
}
|
||||
|
||||
export const managerRouter = createTRPCRouter({
|
||||
requestExport: protectedProcedure.input(exportPhotosInputSchema).mutation(async ({ctx,input}) => {
|
||||
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
||||
const [job] = await getDb().insert(photoExports).values({eventId:input.eventId,requestedBy:ctx.session.user.id,filter:input.filter,expiresAt:new Date(Date.now()+86400000)}).onConflictDoNothing().returning({id:photoExports.id});
|
||||
if (!job) throw new TRPCError({code:"CONFLICT",message:"An export is already queued or processing."});
|
||||
await writeAudit({eventId:input.eventId,actorUserId:ctx.session.user.id,action:"photo.export.requested",subjectType:"export",subjectId:job.id,metadata:{filter:input.filter}});
|
||||
return job;
|
||||
}),
|
||||
exports: protectedProcedure.input(z.object({eventId:z.string().uuid()})).query(async ({ctx,input}) => {
|
||||
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
||||
return getDb().select({id:photoExports.id,status:photoExports.status,filter:photoExports.filter,total:photoExports.total,processed:photoExports.processed,expiresAt:photoExports.expiresAt}).from(photoExports)
|
||||
.where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.requestedBy,ctx.session.user.id))).orderBy(desc(photoExports.createdAt)).limit(10);
|
||||
}),
|
||||
downloadExport: protectedProcedure.input(z.object({eventId:z.string().uuid(),exportId:z.string().uuid()})).mutation(async ({ctx,input}) => {
|
||||
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
||||
const [job] = await getDb().select().from(photoExports).where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.id,input.exportId),eq(photoExports.requestedBy,ctx.session.user.id)));
|
||||
if (!job || job.status !== "ready" || job.expiresAt <= new Date()) throw new TRPCError({code:"NOT_FOUND",message:"Export is unavailable or expired."});
|
||||
return {url:await createPresignedGetUrl(`exports/${input.eventId}/${job.id}.zip`,Math.max(1,Math.min(300,Math.floor((job.expiresAt.getTime()-Date.now())/1000))))};
|
||||
}),
|
||||
searchLocations: protectedProcedure.input(locationSearchInputSchema).query(async ({ ctx, input }) => {
|
||||
const platformRole = await getPlatformRole(ctx.session.user.id);
|
||||
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, platformRole);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {test,expect} from "bun:test";
|
||||
import {and,eq,ne} from "drizzle-orm";
|
||||
import {events,eventMemberships,getDb,groups,photoExports,user} from "@album/database";
|
||||
import {managerRouter} from "./api/routers/manager";
|
||||
import type {TrpcContext} from "./api/trpc";
|
||||
|
||||
test.skipIf(process.env.EXPORT_PERMISSIONS_INTEGRATION!=="1")("export authorization, requester scoping, revoked access and expiry",async()=>{
|
||||
if (!["localhost","127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
|
||||
const db=getDb();
|
||||
const id=crypto.randomUUID();
|
||||
const [person]=await db.insert(user).values({id,name:"Export test",email:`${id}@manyangles.test`,emailVerified:true}).returning();
|
||||
const [group]=await db.select({id:groups.id}).from(groups).limit(1);
|
||||
const [event]=await db.insert(events).values({groupId:group!.id,title:"Permission test",slug:`export-access-${id}`}).returning();
|
||||
const ctx:TrpcContext={session:{user:person!,session:{}} as TrpcContext["session"],cookies:new Map(),activeGroupId:null,requestOrigin:"http://localhost:3000",clientIdentifier:id,guestTokenForEvent:()=>null,setCookies:[],appendSetCookie:()=>{}};
|
||||
const caller=managerRouter.createCaller(ctx);
|
||||
try {
|
||||
await expect(caller.requestExport({eventId:event!.id,filter:"all"})).rejects.toThrow();
|
||||
await db.insert(eventMemberships).values({eventId:event!.id,userId:id,role:"viewer"});
|
||||
await expect(caller.requestExport({eventId:event!.id,filter:"all"})).rejects.toThrow();
|
||||
await db.update(eventMemberships).set({role:"owner"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
|
||||
const job=await caller.requestExport({eventId:event!.id,filter:"all"});
|
||||
const scope=and(eq(photoExports.eventId,event!.id),eq(photoExports.id,job.id));
|
||||
await db.update(photoExports).set({status:"ready"}).where(scope);
|
||||
expect((await caller.downloadExport({eventId:event!.id,exportId:job.id})).url).toContain("X-Amz-Expires=300");
|
||||
const [other]=await db.select({id:user.id}).from(user).where(ne(user.id,id)).limit(1);
|
||||
await db.update(photoExports).set({requestedBy:other!.id}).where(scope);
|
||||
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow();
|
||||
await db.update(photoExports).set({requestedBy:id}).where(scope);
|
||||
await expect(caller.downloadExport({eventId:crypto.randomUUID(),exportId:job.id})).rejects.toThrow();
|
||||
await db.update(eventMemberships).set({role:"viewer"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
|
||||
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow();
|
||||
await db.update(eventMemberships).set({role:"owner"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
|
||||
await db.update(photoExports).set({expiresAt:new Date(0)}).where(scope);
|
||||
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow("expired");
|
||||
} finally {
|
||||
await db.delete(events).where(eq(events.id,event!.id));
|
||||
await db.delete(user).where(eq(user.id,id));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user