chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import type { RoleWithResourceCount } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
"#6366f1", "#8b5cf6", "#ec4899", "#ef4444", "#f97316",
|
||||
"#eab308", "#22c55e", "#14b8a6", "#06b6d4", "#3b82f6",
|
||||
];
|
||||
|
||||
interface RoleModalProps {
|
||||
role?: RoleWithResourceCount | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
|
||||
const isEditing = Boolean(role);
|
||||
|
||||
const [name, setName] = useState(role?.name ?? "");
|
||||
const [description, setDescription] = useState(role?.description ?? "");
|
||||
const [color, setColor] = useState(role?.color ?? PRESET_COLORS[0]!);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createMutation = trpc.role.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.role.list.invalidate();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => setServerError(err.message),
|
||||
});
|
||||
|
||||
const updateMutation = trpc.role.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.role.list.invalidate();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => setServerError(err.message),
|
||||
});
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setServerError(null);
|
||||
|
||||
if (!name.trim()) {
|
||||
setServerError("Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
color: color || undefined,
|
||||
};
|
||||
|
||||
if (isEditing && role) {
|
||||
updateMutation.mutate({ id: role.id, data: payload });
|
||||
} else {
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
|
||||
const labelClass = "block text-sm font-medium text-gray-700 mb-1";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{isEditing ? "Edit Role" : "New Role"}
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Name <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setServerError(null); }}
|
||||
placeholder="e.g. 3D Artist"
|
||||
className={inputClass}
|
||||
maxLength={100}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
className={inputClass}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Color</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-gray-200 flex-shrink-0" style={{ backgroundColor: color }} />
|
||||
<div className="flex flex-wrap gap-2 flex-1">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className="w-6 h-6 rounded-full border-2 transition-transform hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: color === c ? "#1f2937" : "transparent",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-8 h-8 rounded cursor-pointer border border-gray-300"
|
||||
title="Custom color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} disabled={isPending} className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 disabled:opacity-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={isPending} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50">
|
||||
{isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user