fiddy/apps/web/components/new-bucket-modal.tsx
Nico f8e426542d
Some checks failed
Build & Deploy Fiddy (Dokploy) / build (push) Has been cancelled
Build & Deploy Fiddy (Dokploy) / deploy (push) Has been cancelled
feat: implement schedules pivot, scheduler service, and dokploy deploy flow
2026-02-15 17:10:58 -08:00

233 lines
11 KiB
TypeScript

"use client";
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import TagInput from "@/components/tag-input";
import { bucketIcons } from "@/lib/shared/bucket-icons";
import ToggleButtonGroup from "@/components/toggle-button-group";
type BucketForm = {
name: string;
description: string;
iconKey: string;
budgetLimitDollars: string;
tags: string[];
necessity: string;
windowDays: string;
};
type NewBucketModalProps = {
isOpen: boolean;
title: string;
form: BucketForm;
error: string;
onClose: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onChange: (next: Partial<BucketForm>) => void;
tagSuggestions: string[];
canDelete?: boolean;
onDelete?: () => void;
};
export default function NewBucketModal({ isOpen, title, form, error, onClose, onSubmit, onChange, tagSuggestions, canDelete = false, onDelete }: NewBucketModalProps) {
const [iconModalOpen, setIconModalOpen] = useState(false);
const [iconSearch, setIconSearch] = useState("");
const formRef = useRef<HTMLFormElement | null>(null);
const normalizedSearch = iconSearch.trim().toLowerCase();
const filteredIcons = useMemo(() => {
if (!normalizedSearch) return bucketIcons;
return bucketIcons.filter(item => item.label.toLowerCase().includes(normalizedSearch) || item.key.toLowerCase().includes(normalizedSearch));
}, [normalizedSearch]);
const iconMap = useMemo(() => new Map(bucketIcons.map(item => [item.key, item.icon])), []);
const selectedIcon = form.iconKey ? iconMap.get(form.iconKey) : iconMap.get("none");
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (iconModalOpen) setIconModalOpen(false);
else onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [iconModalOpen, isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 !mt-0"
onClick={onClose}
>
<div
className="relative w-full max-w-xl rounded-xl border border-accent-weak bg-panel p-4"
onClick={event => event.stopPropagation()}
>
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 rounded-lg btn-outline-accent px-2 py-1 text-sm"
aria-label="Close"
>
</button>
<div className="text-lg font-semibold">{title}</div>
<form
ref={formRef}
onSubmit={onSubmit}
onKeyDown={event => {
if (event.key !== "Enter" || event.defaultPrevented || event.shiftKey) return;
const target = event.target as HTMLElement;
if (target?.tagName === "TEXTAREA") return;
event.preventDefault();
formRef.current?.requestSubmit();
}}
className="mt-3 grid gap-3 md:grid-cols-2"
>
<label className="text-sm text-muted md:col-span-2">
<div className="mt-1 flex items-center gap-2">
<div
className="flex h-10 w-12 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg"
onClick={() => setIconModalOpen(true)}
>
{selectedIcon || "🚫"}
</div>
<input
name="name"
className={`w-full input-base px-3 py-2 text-sm ${form.name.trim() ? "" : "border-red-400/70"}`}
value={form.name}
onChange={e => onChange({ name: e.target.value })}
required
/>
</div>
</label>
<label className="text-sm text-muted">
Budget limit ($)
<input
name="budgetLimitDollars"
type="number"
min={0}
step="0.01"
className="mt-1 w-full input-base px-3 py-2 text-sm"
value={form.budgetLimitDollars}
placeholder="none"
onChange={e => onChange({ budgetLimitDollars: e.target.value })}
/>
</label>
<label className="text-sm text-muted">
Window days
<input
name="windowDays"
type="number"
min={1}
max={365}
className="mt-1 w-full input-base px-3 py-2 text-sm"
value={form.windowDays}
onChange={e => onChange({ windowDays: e.target.value })}
/>
</label>
<div className="text-sm text-muted md:col-span-2">
<ToggleButtonGroup
value={form.necessity}
onChange={necessity => onChange({ necessity })}
ariaLabel="Necessity"
className="mt-1 flex items-center gap-2 rounded-full border border-accent-weak bg-panel"
sizeClassName="px-3 py-2.5 text-xs font-semibold"
options={[
{ value: "NECESSARY", label: "Necessary", className: "flex-1" },
{ value: "BOTH", label: "Both", className: "flex-1" },
{ value: "UNNECESSARY", label: "Unnecessary", className: "flex-1" }
]}
/>
</div>
<label className="text-sm text-muted md:col-span-2">
Description
<textarea
name="description"
className="mt-1 w-full input-base px-3 py-2 text-sm"
rows={2}
value={form.description}
onChange={e => onChange({ description: e.target.value })}
/>
</label>
<TagInput
label="Bucket tags"
tags={form.tags}
suggestions={tagSuggestions}
allowCustom={false}
onToggleTag={tag => onChange({ tags: form.tags.filter(item => item !== tag) })}
onAddTag={tag => onChange({ tags: [...form.tags, tag] })}
/>
<div className="md:col-span-2 flex items-center justify-between gap-3">
{error ? <div className="text-sm text-red-400">{error}</div> : null}
<div className="ml-auto flex items-center gap-2">
<button type="submit" className="rounded-lg btn-accent px-4 py-2 text-sm font-semibold">
Save bucket
</button>
{canDelete ? (
<button
type="button"
className="rounded-lg border border-red-400/70 bg-red-500/10 px-4 py-2 text-sm font-semibold text-red-200 hover:bg-red-500/15"
onClick={onDelete}
>
Delete bucket
</button>
) : null}
</div>
</div>
</form>
</div>
{iconModalOpen ? (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4" onClick={() => setIconModalOpen(false)}>
<div className="w-full max-w-lg rounded-2xl border border-accent-weak bg-panel p-4" onClick={event => event.stopPropagation()}>
<div className="flex items-center justify-between">
<div className="text-lg font-semibold">Pick an icon</div>
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-sm"
onClick={() => setIconModalOpen(false)}
aria-label="Close"
>
</button>
</div>
<input
type="text"
className="mt-3 w-full input-base px-3 py-2 text-sm"
placeholder="Search icons"
value={iconSearch}
onChange={e => setIconSearch(e.target.value)}
/>
<div className="mt-3 max-h-[50vh] overflow-auto rounded-lg border border-accent-weak bg-surface p-2">
<div className="grid grid-cols-6 gap-2 sm:grid-cols-8">
{filteredIcons.map(item => (
<button
key={item.key}
type="button"
className={`flex h-10 w-10 items-center justify-center rounded-lg border text-lg ${form.iconKey === item.key ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel hover:border-accent"}`}
onClick={() => {
onChange({ iconKey: item.key });
setIconModalOpen(false);
}}
aria-label={item.label}
title={item.label}
>
{item.icon}
</button>
))}
</div>
{!filteredIcons.length ? (
<div className="py-6 text-center text-sm text-soft">No matching icons.</div>
) : null}
</div>
</div>
</div>
) : null}
</div>
);
}