feat: add snippets features

This commit is contained in:
LukeGus
2026-05-26 23:02:19 -05:00
parent 0a32bf518e
commit d01b1a03cb
2 changed files with 250 additions and 23 deletions
+9
View File
@@ -1847,6 +1847,15 @@
"deleteFailed": "Failed to delete snippet", "deleteFailed": "Failed to delete snippet",
"folderCreateSuccess": "Folder created successfully", "folderCreateSuccess": "Folder created successfully",
"folderCreateFailed": "Failed to create folder", "folderCreateFailed": "Failed to create folder",
"editFolderTitle": "Edit Folder",
"editFolderDescription": "Rename or change the appearance of this folder",
"saveFolderButton": "Save Changes",
"editFolder": "Edit folder",
"deleteFolder": "Delete folder",
"folderDeleteSuccess": "Folder \"{{name}}\" deleted",
"folderDeleteFailed": "Failed to delete folder",
"folderEditSuccess": "Folder updated successfully",
"folderEditFailed": "Failed to update folder",
"runSuccess": "Ran \"{{name}}\" in {{count}} terminal(s)", "runSuccess": "Ran \"{{name}}\" in {{count}} terminal(s)",
"copySuccess": "Copied \"{{name}}\" to clipboard", "copySuccess": "Copied \"{{name}}\" to clipboard",
"shareTitle": "Share Snippet", "shareTitle": "Share Snippet",
+241 -23
View File
@@ -7,6 +7,9 @@ import {
deleteSnippet as apiDeleteSnippet, deleteSnippet as apiDeleteSnippet,
getSnippetFolders, getSnippetFolders,
createSnippetFolder as apiCreateSnippetFolder, createSnippetFolder as apiCreateSnippetFolder,
deleteSnippetFolder as apiDeleteSnippetFolder,
renameSnippetFolder as apiRenameSnippetFolder,
updateSnippetFolderMetadata as apiUpdateSnippetFolderMetadata,
shareSnippet as apiShareSnippet, shareSnippet as apiShareSnippet,
getSnippetAccess, getSnippetAccess,
revokeSnippetAccess, revokeSnippetAccess,
@@ -43,7 +46,14 @@ import {
Trash2, Trash2,
UserPlus, UserPlus,
X, X,
MoreHorizontal,
} from "lucide-react"; } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { toast } from "sonner"; import { toast } from "sonner";
import { FOLDER_COLORS } from "@/lib/theme"; import { FOLDER_COLORS } from "@/lib/theme";
import { FOLDER_ICONS } from "@/types/ui-types"; import { FOLDER_ICONS } from "@/types/ui-types";
@@ -354,6 +364,130 @@ function CreateFolderDialog({
); );
} }
function EditFolderDialog({
open,
onOpenChange,
folder,
onSave,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
folder: SnippetFolder | null;
onSave: (
oldName: string,
data: { name: string; color: string; icon: FolderIconId },
) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [color, setColor] = useState(FOLDER_COLORS[0]);
const [icon, setIcon] = useState<FolderIconId>("folder");
useEffect(() => {
if (open && folder) {
setName(folder.name);
setColor(folder.color ?? FOLDER_COLORS[0]);
setIcon(folder.icon ?? "folder");
}
}, [open, folder]);
function handleSave() {
if (!name.trim() || !folder) return;
onSave(folder.name, { name: name.trim(), color, icon });
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("newUi.sidebar.snippets.editFolderTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.snippets.editFolderDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.snippets.folderNameLabel")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("newUi.sidebar.snippets.folderNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("newUi.sidebar.snippets.folderColorLabel")}
</label>
<div className="grid grid-cols-4 gap-2">
{FOLDER_COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`h-10 transition-all ${color === c ? "ring-2 ring-offset-2 ring-offset-background ring-white/50" : "opacity-75 hover:opacity-100"}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("newUi.sidebar.snippets.folderIconLabel")}
</label>
<div className="grid grid-cols-5 gap-2">
{FOLDER_ICONS.map((ic) => (
<button
key={ic}
onClick={() => setIcon(ic)}
className={`flex items-center justify-center h-11 border transition-colors ${
icon === ic
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground"
}`}
>
<FolderIconEl icon={ic} className="size-5" />
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.snippets.previewLabel")}
</label>
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
<FolderIconEl
icon={icon}
className="size-4 shrink-0"
style={{ color }}
/>
<span className="text-sm font-semibold">
{name || t("newUi.sidebar.snippets.folderNameFallback")}
</span>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("newUi.sidebar.snippets.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
>
{t("newUi.sidebar.snippets.saveFolderButton")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
type AccessRecord = { type AccessRecord = {
id: number; id: number;
targetType: "user" | "role"; targetType: "user" | "role";
@@ -386,7 +520,6 @@ function ShareSnippetDialog({
setLoading(true); setLoading(true);
Promise.all([getUserList(), getRoles(), getSnippetAccess(snippet.id)]) Promise.all([getUserList(), getRoles(), getSnippetAccess(snippet.id)])
.then(([usersData, rolesData, accessData]) => { .then(([usersData, rolesData, accessData]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setUsers( setUsers(
(usersData?.users || []).map((u: any) => ({ (usersData?.users || []).map((u: any) => ({
id: u.id, id: u.id,
@@ -679,6 +812,8 @@ export function SnippetsPanel({
const [snippetFormOpen, setSnippetFormOpen] = useState(false); const [snippetFormOpen, setSnippetFormOpen] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null); const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
const [createFolderOpen, setCreateFolderOpen] = useState(false); const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [editFolder, setEditFolder] = useState<SnippetFolder | null>(null);
const [editFolderOpen, setEditFolderOpen] = useState(false);
const [shareSnippet, setShareSnippet] = useState<Snippet | null>(null); const [shareSnippet, setShareSnippet] = useState<Snippet | null>(null);
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>( const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
() => () =>
@@ -762,7 +897,6 @@ export function SnippetsPanel({
async function handleCreateFolder(f: Omit<SnippetFolder, "id" | "open">) { async function handleCreateFolder(f: Omit<SnippetFolder, "id" | "open">) {
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const created = (await apiCreateSnippetFolder({ const created = (await apiCreateSnippetFolder({
name: f.name, name: f.name,
color: f.color, color: f.color,
@@ -781,6 +915,54 @@ export function SnippetsPanel({
); );
} }
async function handleDeleteFolder(folder: SnippetFolder) {
try {
await apiDeleteSnippetFolder(folder.name);
setFolders((prev) => prev.filter((f) => f.id !== folder.id));
setSnippets((prev) =>
prev.map((s) =>
s.folder === folder.name ? { ...s, folder: null } : s,
),
);
toast.success(
t("newUi.sidebar.snippets.folderDeleteSuccess", { name: folder.name }),
);
} catch {
toast.error(t("newUi.sidebar.snippets.folderDeleteFailed"));
}
}
async function handleSaveFolder(
oldName: string,
data: { name: string; color: string; icon: FolderIconId },
) {
try {
const nameChanged = data.name !== oldName;
if (nameChanged) {
await apiRenameSnippetFolder(oldName, data.name);
setSnippets((prev) =>
prev.map((s) =>
s.folder === oldName ? { ...s, folder: data.name } : s,
),
);
}
await apiUpdateSnippetFolderMetadata(nameChanged ? data.name : oldName, {
color: data.color,
icon: data.icon,
});
setFolders((prev) =>
prev.map((f) =>
f.name === oldName
? { ...f, name: data.name, color: data.color, icon: data.icon }
: f,
),
);
toast.success(t("newUi.sidebar.snippets.folderEditSuccess"));
} catch {
toast.error(t("newUi.sidebar.snippets.folderEditFailed"));
}
}
async function handleDeleteSnippet(id: number) { async function handleDeleteSnippet(id: number) {
try { try {
await apiDeleteSnippet(id); await apiDeleteSnippet(id);
@@ -956,28 +1138,55 @@ export function SnippetsPanel({
if (folderSnippets.length === 0 && snippetSearch) return null; if (folderSnippets.length === 0 && snippetSearch) return null;
return ( return (
<div key={folder.id} className="flex flex-col gap-2"> <div key={folder.id} className="flex flex-col gap-2">
<button <div className="flex items-center gap-1.5 w-full group">
onClick={() => toggleFolder(folder.id)} <button
className="flex items-center gap-1.5 w-full text-left" onClick={() => toggleFolder(folder.id)}
> className="flex items-center gap-1.5 flex-1 min-w-0 text-left"
<ChevronDown
className={`size-3 text-muted-foreground shrink-0 transition-transform ${folder.open ? "" : "-rotate-90"}`}
/>
<FolderIconEl
icon={folder.icon}
className="size-3.5 shrink-0"
style={{ color: folder.color }}
/>
<span
className="text-xs font-semibold flex-1 truncate"
style={{ color: folder.color }}
> >
{folder.name} <ChevronDown
</span> className={`size-3 text-muted-foreground shrink-0 transition-transform ${folder.open ? "" : "-rotate-90"}`}
<span className="text-xs text-muted-foreground shrink-0"> />
{folderSnippets.length} <FolderIconEl
</span> icon={folder.icon}
</button> className="size-3.5 shrink-0"
style={{ color: folder.color }}
/>
<span
className="text-xs font-semibold flex-1 truncate"
style={{ color: folder.color }}
>
{folder.name}
</span>
<span className="text-xs text-muted-foreground shrink-0 mr-1">
{folderSnippets.length}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="shrink-0 size-5 flex items-center justify-center text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity">
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
<DropdownMenuItem
onClick={() => {
setEditFolder(folder);
setEditFolderOpen(true);
}}
>
<Pencil className="size-3.5 mr-2" />
{t("newUi.sidebar.snippets.editFolder")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteFolder(folder)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="size-3.5 mr-2" />
{t("newUi.sidebar.snippets.deleteFolder")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{folder.open && ( {folder.open && (
<div className="flex flex-col gap-2 ml-1"> <div className="flex flex-col gap-2 ml-1">
{folderSnippets.map((snippet) => ( {folderSnippets.map((snippet) => (
@@ -1020,6 +1229,15 @@ export function SnippetsPanel({
onOpenChange={setCreateFolderOpen} onOpenChange={setCreateFolderOpen}
onCreate={handleCreateFolder} onCreate={handleCreateFolder}
/> />
<EditFolderDialog
open={editFolderOpen}
onOpenChange={(v) => {
setEditFolderOpen(v);
if (!v) setEditFolder(null);
}}
folder={editFolder}
onSave={handleSaveFolder}
/>
<ShareSnippetDialog <ShareSnippetDialog
snippet={shareSnippet} snippet={shareSnippet}
onClose={() => setShareSnippet(null)} onClose={() => setShareSnippet(null)}