| // src/components/project/ProjectSettings.tsx | |
| import { useState } from "react"; | |
| import { Save, AlertTriangle, Globe, Lock, Users, Archive, Trash2, X, Plus, Loader2, Settings } from "lucide-react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| interface ProjectSettingsProps { | |
| project: { | |
| id: number; | |
| name: string; | |
| description: string; | |
| status: string; | |
| visibility: string; | |
| tags: string[]; | |
| }; | |
| onUpdate: (updates: any) => void; | |
| onArchive: () => void; | |
| onDelete: () => void; | |
| } | |
| const VISIBILITY_OPTIONS = [ | |
| { value: "private", label: "Private", desc: "Only invited members", icon: Lock, style: "text-red-600 bg-red-50 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/50" }, | |
| { value: "team", label: "Team", desc: "Organization members", icon: Users, style: "text-amber-600 bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/50" }, | |
| { value: "public", label: "Public", desc: "Anyone can view", icon: Globe, style: "text-emerald-600 bg-emerald-50 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800/50" }, | |
| ]; | |
| export function ProjectSettings({ project, onUpdate, onArchive, onDelete }: ProjectSettingsProps) { | |
| const [name, setName] = useState(project.name); | |
| const [description, setDescription] = useState(project.description || ""); | |
| const [visibility, setVisibility] = useState(project.visibility); | |
| const [status, setStatus] = useState(project.status); | |
| const [tags, setTags] = useState<string[]>(project.tags || []); | |
| const [newTag, setNewTag] = useState(""); | |
| const [saving, setSaving] = useState(false); | |
| const handleAddTag = () => { | |
| const tag = newTag.trim().toLowerCase().replace(/\s+/g, "-"); | |
| if (tag && !tags.includes(tag) && tags.length < 10) { | |
| setTags([...tags, tag]); | |
| setNewTag(""); | |
| } | |
| }; | |
| const handleSave = async () => { | |
| if (!name.trim()) return; | |
| setSaving(true); | |
| try { | |
| await onUpdate({ name: name.trim(), description: description.trim(), visibility, status, tags }); | |
| } catch { | |
| // error handled by parent | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| return ( | |
| <div className="space-y-6 max-w-2xl"> | |
| <div className="flex items-center gap-2 mb-6"> | |
| <Settings className="w-5 h-5 text-primary" /> | |
| <h3 className="text-base font-bold text-gray-900 dark:text-white">Project Settings</h3> | |
| </div> | |
| {/* Basic Info */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-2xl border border-white/30 dark:border-gray-700/50 p-6 shadow-sm space-y-5" | |
| > | |
| <h4 className="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider">Basic Info</h4> | |
| <div> | |
| <label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5"> | |
| Project Name <span className="text-red-500">*</span> | |
| </label> | |
| <input | |
| type="text" | |
| value={name} | |
| onChange={e => setName(e.target.value)} | |
| maxLength={100} | |
| className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white/80 dark:bg-gray-800/80 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent transition" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">Description</label> | |
| <textarea | |
| value={description} | |
| onChange={e => setDescription(e.target.value)} | |
| rows={4} | |
| maxLength={1000} | |
| className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white/80 dark:bg-gray-800/80 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent transition resize-none" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Status</label> | |
| <div className="flex gap-2 flex-wrap"> | |
| {["active", "completed", "archived"].map(s => ( | |
| <button | |
| key={s} | |
| type="button" | |
| onClick={() => setStatus(s)} | |
| className={`px-4 py-2 rounded-xl text-sm font-medium border transition-all capitalize ${ | |
| status === s | |
| ? "bg-primary text-white border-primary shadow-sm shadow-primary/20" | |
| : "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-primary/40" | |
| }`} | |
| > | |
| {s} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </motion.div> | |
| {/* Visibility */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.05 }} | |
| className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-2xl border border-white/30 dark:border-gray-700/50 p-6 shadow-sm" | |
| > | |
| <h4 className="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-4">Visibility</h4> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> | |
| {VISIBILITY_OPTIONS.map(opt => { | |
| const Icon = opt.icon; | |
| const selected = visibility === opt.value; | |
| return ( | |
| <button | |
| key={opt.value} | |
| type="button" | |
| onClick={() => setVisibility(opt.value)} | |
| className={`p-4 rounded-2xl border-2 text-left transition-all ${ | |
| selected ? `${opt.style} ring-2 ring-offset-1 ring-current` : "border-gray-200 dark:border-gray-700 bg-white/50 dark:bg-gray-800/50 hover:bg-white dark:hover:bg-gray-800" | |
| }`} | |
| > | |
| <Icon className="w-5 h-5 mb-2" /> | |
| <div className="font-bold text-sm capitalize">{opt.label}</div> | |
| <div className="text-xs opacity-70 mt-0.5">{opt.desc}</div> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </motion.div> | |
| {/* Tags */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.1 }} | |
| className="bg-white/60 dark:bg-gray-900/60 backdrop-blur-md rounded-2xl border border-white/30 dark:border-gray-700/50 p-6 shadow-sm" | |
| > | |
| <h4 className="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-4">Tags</h4> | |
| <div className="flex gap-2 mb-3"> | |
| <input | |
| type="text" | |
| value={newTag} | |
| onChange={e => setNewTag(e.target.value)} | |
| onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); handleAddTag(); } }} | |
| placeholder="Add tag..." | |
| disabled={tags.length >= 10} | |
| className="flex-1 px-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white/80 dark:bg-gray-800/80 text-sm focus:ring-2 focus:ring-primary focus:border-transparent transition" | |
| /> | |
| <button | |
| type="button" | |
| onClick={handleAddTag} | |
| disabled={!newTag.trim() || tags.length >= 10} | |
| className="p-2.5 rounded-xl bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 transition" | |
| > | |
| <Plus className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| <AnimatePresence> | |
| {tags.map(tag => ( | |
| <motion.span | |
| key={tag} | |
| initial={{ scale: 0.8, opacity: 0 }} | |
| animate={{ scale: 1, opacity: 1 }} | |
| exit={{ scale: 0.8, opacity: 0 }} | |
| className="flex items-center gap-1.5 px-3 py-1 text-xs bg-primary/10 text-primary border border-primary/20 rounded-full" | |
| > | |
| #{tag} | |
| <button type="button" onClick={() => setTags(tags.filter(t => t !== tag))} className="hover:text-red-500 transition-colors"> | |
| <X className="w-3 h-3" /> | |
| </button> | |
| </motion.span> | |
| ))} | |
| </AnimatePresence> | |
| </div> | |
| </motion.div> | |
| {/* Save Button */} | |
| <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.15 }}> | |
| <motion.button | |
| whileHover={{ scale: 1.02 }} | |
| whileTap={{ scale: 0.98 }} | |
| onClick={handleSave} | |
| disabled={saving || !name.trim()} | |
| className="w-full sm:w-auto flex items-center justify-center gap-2 px-8 py-3.5 bg-gradient-to-r from-primary to-indigo-600 text-white rounded-2xl font-bold shadow-lg shadow-primary/20 hover:shadow-primary/40 disabled:opacity-50 transition-all" | |
| > | |
| {saving ? <><Loader2 className="w-5 h-5 animate-spin" /> Saving...</> : <><Save className="w-5 h-5" /> Save Settings</>} | |
| </motion.button> | |
| </motion.div> | |
| {/* Danger Zone */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.2 }} | |
| className="bg-red-50/60 dark:bg-red-900/10 backdrop-blur-md rounded-2xl border border-red-200 dark:border-red-800/50 p-6" | |
| > | |
| <h4 className="text-sm font-bold text-red-700 dark:text-red-400 uppercase tracking-wider mb-4 flex items-center gap-2"> | |
| <AlertTriangle className="w-4 h-4" /> Danger Zone | |
| </h4> | |
| <div className="space-y-4"> | |
| <div className="flex items-center justify-between flex-wrap gap-4"> | |
| <div> | |
| <p className="font-semibold text-red-700 dark:text-red-400 text-sm">Archive Project</p> | |
| <p className="text-xs text-red-600/70 dark:text-red-500/70 mt-0.5">Move to archive. You can restore it later.</p> | |
| </div> | |
| <button | |
| onClick={onArchive} | |
| className="flex items-center gap-2 px-4 py-2 rounded-xl border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition text-sm font-medium" | |
| > | |
| <Archive className="w-4 h-4" /> Archive | |
| </button> | |
| </div> | |
| <div className="border-t border-red-200 dark:border-red-800/50 pt-4 flex items-center justify-between flex-wrap gap-4"> | |
| <div> | |
| <p className="font-semibold text-red-700 dark:text-red-400 text-sm">Delete Project</p> | |
| <p className="text-xs text-red-600/70 dark:text-red-500/70 mt-0.5">Permanently delete all data. Cannot be undone.</p> | |
| </div> | |
| <button | |
| onClick={onDelete} | |
| className="flex items-center gap-2 px-4 py-2 rounded-xl bg-red-500 text-white hover:bg-red-600 transition text-sm font-medium shadow-sm shadow-red-500/20" | |
| > | |
| <Trash2 className="w-4 h-4" /> Delete Forever | |
| </button> | |
| </div> | |
| </div> | |
| </motion.div> | |
| </div> | |
| ); | |
| } |
Xet Storage Details
- Size:
- 11.2 kB
- Xet hash:
- c95a9c91af8872ea81b7ee74317bb5de6ec6fbf77cdd9af85e4062fc5e31b04c
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.