diff --git a/docs/plans/2025-12-17-shadcn-ui-migration.md b/docs/plans/2025-12-17-shadcn-ui-migration.md new file mode 100644 index 0000000..f334392 --- /dev/null +++ b/docs/plans/2025-12-17-shadcn-ui-migration.md @@ -0,0 +1,1995 @@ +# shadcn/ui Migration - Clean & Minimal UI Redesign + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Migrate from MUI Joy UI to shadcn/ui with a clean, minimal design aesthetic (Linear/Vercel style) + +**Architecture:** Phased migration replacing MUI Joy components with shadcn/ui primitives built on Radix UI and Tailwind CSS. Keep existing functionality while improving visual polish, consistency, and maintainability. Enhanced with subtle animations and improved spacing. + +**Tech Stack:** +- shadcn/ui (Radix UI primitives + Tailwind) +- Framer Motion (animations) +- class-variance-authority (component variants) +- tailwind-merge & clsx (utility functions) +- Existing: React 18, TypeScript, Vite, lucide-react, Zustand + +--- + +## Phase 1: Setup & Configuration + +### Task 1: Install shadcn/ui Dependencies + +**Files:** +- Modify: `frontend/web/package.json` + +**Step 1: Install required dependencies** + +Run: +```bash +cd frontend/web +pnpm add class-variance-authority clsx tailwind-merge +pnpm add -D @types/node +``` + +Expected: Dependencies installed successfully + +**Step 2: Verify installation** + +Run: +```bash +pnpm list class-variance-authority clsx tailwind-merge +``` + +Expected: All three packages listed with versions + +**Step 3: Commit** + +```bash +git add frontend/web/package.json frontend/web/pnpm-lock.yaml +git commit -m "feat: add shadcn/ui base dependencies" +``` + +--- + +### Task 2: Configure shadcn/ui + +**Files:** +- Create: `frontend/web/components.json` +- Modify: `frontend/web/tsconfig.json` + +**Step 1: Create components.json configuration** + +Create `frontend/web/components.json`: +```json +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/css/index.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components/ui", + "utils": "@/lib/utils" + } +} +``` + +**Step 2: Update tsconfig.json with path aliases** + +Add to `frontend/web/tsconfig.json` compilerOptions.paths: +```json +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + } +} +``` + +**Step 3: Verify configuration** + +Run: +```bash +cat frontend/web/components.json +``` + +Expected: File exists with correct JSON + +**Step 4: Commit** + +```bash +git add frontend/web/components.json frontend/web/tsconfig.json +git commit -m "feat: configure shadcn/ui setup" +``` + +--- + +### Task 3: Create Utility Functions + +**Files:** +- Create: `frontend/web/src/lib/utils.ts` + +**Step 1: Create lib directory and utils file** + +Run: +```bash +mkdir -p frontend/web/src/lib +``` + +Create `frontend/web/src/lib/utils.ts`: +```typescript +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +**Step 2: Verify file creation** + +Run: +```bash +cat frontend/web/src/lib/utils.ts +``` + +Expected: File contains cn utility function + +**Step 3: Commit** + +```bash +git add frontend/web/src/lib/utils.ts +git commit -m "feat: add cn utility for class merging" +``` + +--- + +### Task 4: Update Tailwind Configuration for shadcn/ui + +**Files:** +- Modify: `frontend/web/tailwind.config.js` +- Modify: `frontend/web/src/css/index.css` + +**Step 1: Update tailwind.config.js with shadcn theme** + +Replace `frontend/web/tailwind.config.js`: +```javascript +/* eslint-disable no-undef */ +const { fontFamily } = require("tailwindcss/defaultTheme"); + +module.exports = { + content: ["./index.html", "./src/**/*.{js,ts,tsx}"], + darkMode: "class", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + fontSize: { + xs: ".75rem", + sm: ".875rem", + base: "1rem", + lg: "1.125rem", + xl: "1.25rem", + "2xl": "1.5rem", + "3xl": "1.875rem", + "4xl": "2.25rem", + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + fontFamily: { + sans: ["var(--font-sans)", ...fontFamily.sans], + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + maxWidth: { + "8xl": "88rem", + }, + spacing: { + 112: "28rem", + 128: "32rem", + 180: "45rem", + }, + zIndex: { + 1: "1", + 20: "20", + 100: "100", + 1000: "1000", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; +``` + +**Step 2: Install tailwindcss-animate plugin** + +Run: +```bash +cd frontend/web +pnpm add -D tailwindcss-animate +``` + +Expected: Plugin installed + +**Step 3: Add CSS variables to index.css** + +Prepend to `frontend/web/src/css/index.css`: +```css +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} +``` + +**Step 4: Verify CSS compiles** + +Run: +```bash +cd frontend/web +pnpm run build +``` + +Expected: Build succeeds without CSS errors + +**Step 5: Commit** + +```bash +git add frontend/web/tailwind.config.js frontend/web/src/css/index.css frontend/web/package.json frontend/web/pnpm-lock.yaml +git commit -m "feat: configure tailwind with shadcn theme and CSS variables" +``` + +--- + +## Phase 2: Install Core shadcn Components + +### Task 5: Install Button Component + +**Files:** +- Create: `frontend/web/src/components/ui/button.tsx` + +**Step 1: Install button component via shadcn CLI** + +Run: +```bash +cd frontend/web +npx shadcn@latest add button +``` + +Expected: Creates `src/components/ui/button.tsx` + +**Step 2: Verify button component exists** + +Run: +```bash +cat frontend/web/src/components/ui/button.tsx | head -20 +``` + +Expected: File contains Button component with variants + +**Step 3: Commit** + +```bash +git add frontend/web/src/components/ui/button.tsx +git commit -m "feat: add shadcn button component" +``` + +--- + +### Task 6: Install Input Component + +**Files:** +- Create: `frontend/web/src/components/ui/input.tsx` + +**Step 1: Install input component** + +Run: +```bash +cd frontend/web +npx shadcn@latest add input +``` + +Expected: Creates `src/components/ui/input.tsx` + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/input.tsx +git commit -m "feat: add shadcn input component" +``` + +--- + +### Task 7: Install Label Component + +**Files:** +- Create: `frontend/web/src/components/ui/label.tsx` + +**Step 1: Install label component** + +Run: +```bash +cd frontend/web +npx shadcn@latest add label +``` + +Expected: Creates `src/components/ui/label.tsx` + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/label.tsx +git commit -m "feat: add shadcn label component" +``` + +--- + +### Task 8: Install Card Component + +**Files:** +- Create: `frontend/web/src/components/ui/card.tsx` + +**Step 1: Install card component** + +Run: +```bash +cd frontend/web +npx shadcn@latest add card +``` + +Expected: Creates `src/components/ui/card.tsx` + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/card.tsx +git commit -m "feat: add shadcn card component" +``` + +--- + +### Task 9: Install Badge Component + +**Files:** +- Create: `frontend/web/src/components/ui/badge.tsx` + +**Step 1: Install badge component** + +Run: +```bash +cd frontend/web +npx shadcn@latest add badge +``` + +Expected: Creates `src/components/ui/badge.tsx` + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/badge.tsx +git commit -m "feat: add shadcn badge component" +``` + +--- + +### Task 10: Install Separator Component + +**Files:** +- Create: `frontend/web/src/components/ui/separator.tsx` + +**Step 1: Install separator component (replaces MUI Divider)** + +Run: +```bash +cd frontend/web +npx shadcn@latest add separator +``` + +Expected: Creates `src/components/ui/separator.tsx` + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/separator.tsx +git commit -m "feat: add shadcn separator component" +``` + +--- + +## Phase 3: Install Complex Components + +### Task 11: Install Dialog Component + +**Files:** +- Create: `frontend/web/src/components/ui/dialog.tsx` + +**Step 1: Install dialog component** + +Run: +```bash +cd frontend/web +npx shadcn@latest add dialog +``` + +Expected: Creates `src/components/ui/dialog.tsx` with Radix Dialog primitives + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/dialog.tsx +git commit -m "feat: add shadcn dialog component" +``` + +--- + +### Task 12: Install Sheet Component (Drawer Replacement) + +**Files:** +- Create: `frontend/web/src/components/ui/sheet.tsx` + +**Step 1: Install sheet component (replaces MUI Drawer)** + +Run: +```bash +cd frontend/web +npx shadcn@latest add sheet +``` + +Expected: Creates `src/components/ui/sheet.tsx` + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/sheet.tsx +git commit -m "feat: add shadcn sheet component (drawer replacement)" +``` + +--- + +### Task 13: Install Dropdown Menu Component + +**Files:** +- Create: `frontend/web/src/components/ui/dropdown-menu.tsx` + +**Step 1: Install dropdown-menu component** + +Run: +```bash +cd frontend/web +npx shadcn@latest add dropdown-menu +``` + +Expected: Creates `src/components/ui/dropdown-menu.tsx` + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/dropdown-menu.tsx +git commit -m "feat: add shadcn dropdown-menu component" +``` + +--- + +### Task 14: Install Tooltip Component + +**Files:** +- Create: `frontend/web/src/components/ui/tooltip.tsx` + +**Step 1: Install tooltip component** + +Run: +```bash +cd frontend/web +npx shadcn@latest add tooltip +``` + +Expected: Creates `src/components/ui/tooltip.tsx` + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/tooltip.tsx +git commit -m "feat: add shadcn tooltip component" +``` + +--- + +### Task 15: Install Checkbox Component + +**Files:** +- Create: `frontend/web/src/components/ui/checkbox.tsx` + +**Step 1: Install checkbox component** + +Run: +```bash +cd frontend/web +npx shadcn@latest add checkbox +``` + +Expected: Creates `src/components/ui/checkbox.tsx` + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/checkbox.tsx +git commit -m "feat: add shadcn checkbox component" +``` + +--- + +### Task 16: Install Textarea Component + +**Files:** +- Create: `frontend/web/src/components/ui/textarea.tsx` + +**Step 1: Install textarea component** + +Run: +```bash +cd frontend/web +npx shadcn@latest add textarea +``` + +Expected: Creates `src/components/ui/textarea.tsx` + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/textarea.tsx +git commit -m "feat: add shadcn textarea component" +``` + +--- + +### Task 17: Install Avatar Component + +**Files:** +- Create: `frontend/web/src/components/ui/avatar.tsx` + +**Step 1: Install avatar component** + +Run: +```bash +cd frontend/web +npx shadcn@latest add avatar +``` + +Expected: Creates `src/components/ui/avatar.tsx` + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/avatar.tsx +git commit -m "feat: add shadcn avatar component" +``` + +--- + +### Task 18: Install Sonner (Toast Replacement) + +**Files:** +- Create: `frontend/web/src/components/ui/sonner.tsx` + +**Step 1: Install sonner component (modern toast replacement)** + +Run: +```bash +cd frontend/web +npx shadcn@latest add sonner +``` + +Expected: Creates `src/components/ui/sonner.tsx` and installs sonner package + +**Step 2: Commit** + +```bash +git add frontend/web/src/components/ui/sonner.tsx frontend/web/package.json frontend/web/pnpm-lock.yaml +git commit -m "feat: add shadcn sonner component (toast replacement)" +``` + +--- + +## Phase 4: Migrate Application Components + +### Task 19: Migrate Custom Dropdown Component + +**Files:** +- Modify: `frontend/web/src/components/common/Dropdown.tsx` + +**Step 1: Backup original dropdown** + +Run: +```bash +cp frontend/web/src/components/common/Dropdown.tsx frontend/web/src/components/common/Dropdown.tsx.backup +``` + +Expected: Backup created + +**Step 2: Replace with shadcn dropdown-menu** + +Replace `frontend/web/src/components/common/Dropdown.tsx`: +```typescript +import { ReactNode } from "react"; +import Icon from "@/components/Icon"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +interface Props { + trigger?: ReactNode; + actions?: ReactNode; + className?: string; + actionsClassName?: string; +} + +const Dropdown: React.FC = (props: Props) => { + const { trigger, actions, className, actionsClassName } = props; + + return ( + + + {trigger ? ( +
{trigger}
+ ) : ( + + )} +
+ + {actions} + +
+ ); +}; + +export default Dropdown; +``` + +**Step 3: Test dropdown still works** + +Run: +```bash +cd frontend/web +pnpm run dev +``` + +Expected: Dev server starts, dropdown renders without errors + +**Step 4: Commit** + +```bash +git add frontend/web/src/components/common/Dropdown.tsx +git commit -m "refactor: migrate Dropdown to shadcn dropdown-menu" +``` + +--- + +### Task 20: Migrate Alert Component + +**Files:** +- Modify: `frontend/web/src/components/Alert.tsx` + +**Step 1: Install alert-dialog component** + +Run: +```bash +cd frontend/web +npx shadcn@latest add alert-dialog +``` + +Expected: Creates `src/components/ui/alert-dialog.tsx` + +**Step 2: Replace Alert component with shadcn** + +Replace `frontend/web/src/components/Alert.tsx`: +```typescript +import { createRoot } from "react-dom/client"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +type AlertStyle = "default" | "destructive"; + +interface Props { + title: string; + content: string; + style?: AlertStyle; + closeBtnText?: string; + confirmBtnText?: string; + onClose?: () => void; + onConfirm?: () => void; +} + +const defaultProps: Partial = { + style: "default", + closeBtnText: "Close", + confirmBtnText: "Confirm", + onClose: () => null, + onConfirm: () => null, +}; + +const Alert: React.FC = (props: Props) => { + const { title, content, closeBtnText, confirmBtnText, onClose, onConfirm, style } = { + ...defaultProps, + ...props, + }; + + const handleCloseBtnClick = () => { + if (onClose) { + onClose(); + } + }; + + const handleConfirmBtnClick = async () => { + if (onConfirm) { + onConfirm(); + } + }; + + return ( + + + + {title} + {content} + + + + {closeBtnText} + + + {confirmBtnText} + + + + + ); +}; + +export const showCommonDialog = (props: Props) => { + const tempDiv = document.createElement("div"); + const dialog = createRoot(tempDiv); + document.body.append(tempDiv); + + const destroy = () => { + dialog.unmount(); + tempDiv.remove(); + }; + + const onClose = () => { + if (props.onClose) { + props.onClose(); + } + destroy(); + }; + + const onConfirm = () => { + if (props.onConfirm) { + props.onConfirm(); + } + destroy(); + }; + + dialog.render(); +}; + +export default Alert; +``` + +**Step 3: Commit** + +```bash +git add frontend/web/src/components/Alert.tsx frontend/web/src/components/ui/alert-dialog.tsx +git commit -m "refactor: migrate Alert to shadcn alert-dialog" +``` + +--- + +### Task 21: Migrate ShortcutCard Component + +**Files:** +- Modify: `frontend/web/src/components/ShortcutCard.tsx` + +**Step 1: Replace MUI components with shadcn** + +Update `frontend/web/src/components/ShortcutCard.tsx`: +```typescript +import classNames from "classnames"; +import copy from "copy-to-clipboard"; +import { useEffect } from "react"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { absolutifyLink } from "@/helpers/utils"; +import { useUserStore, useViewStore } from "@/stores"; +import { Shortcut } from "@/types/proto/api/v1/shortcut_service"; +import Icon from "./Icon"; +import LinkFavicon from "./LinkFavicon"; +import ShortcutActionsDropdown from "./ShortcutActionsDropdown"; +import VisibilityIcon from "./VisibilityIcon"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +interface Props { + shortcut: Shortcut; +} + +const ShortcutCard = (props: Props) => { + const { shortcut } = props; + const { t } = useTranslation(); + const userStore = useUserStore(); + const viewStore = useViewStore(); + const creator = userStore.getUserById(shortcut.creatorId); + const shortcutLink = absolutifyLink(`/s/${shortcut.name}`); + + useEffect(() => { + userStore.getOrFetchUserById(shortcut.creatorId); + }, []); + + const handleCopyButtonClick = () => { + copy(shortcutLink); + toast.success("Shortcut link copied to clipboard."); + }; + + return ( + +
+ +
+ +
+
+
+ {shortcut.tags.map((tag) => { + return ( + viewStore.setFilter({ tag: tag })} + > + #{tag} + + ); + })} + {shortcut.tags.length === 0 && ( + No tags + )} +
+
+ + + + + + {creator.nickname.substring(0, 2).toUpperCase()} + + + + +

{creator.nickname}

+
+
+
+ + + +
viewStore.setFilter({ visibility: shortcut.visibility })} + > + + {t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.self`)} +
+
+ +

{t(`shortcut.visibility.${shortcut.visibility.toLowerCase()}.description`)}

+
+
+
+ + + + + + {t("shortcut.visits", { count: shortcut.viewCount })} + + + +

View count

+
+
+
+
+
+ ); +}; + +export default ShortcutCard; +``` + +**Step 2: Update main.tsx to include Sonner Toaster** + +Modify `frontend/web/src/main.tsx` - add Toaster component: +```typescript +import { Toaster } from "@/components/ui/sonner"; + +// Add before + +``` + +**Step 3: Test in dev mode** + +Run: +```bash +cd frontend/web +pnpm run dev +``` + +Expected: ShortcutCard renders with new shadcn components + +**Step 4: Commit** + +```bash +git add frontend/web/src/components/ShortcutCard.tsx frontend/web/src/main.tsx +git commit -m "refactor: migrate ShortcutCard to shadcn components" +``` + +--- + +### Task 22: Migrate CreateShortcutDrawer Component + +**Files:** +- Modify: `frontend/web/src/components/CreateShortcutDrawer.tsx` + +**Step 1: Replace MUI Drawer with shadcn Sheet** + +Replace imports and component structure in `frontend/web/src/components/CreateShortcutDrawer.tsx`: +```typescript +import classnames from "classnames"; +import { isUndefined, uniq } from "lodash-es"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import useLoading from "@/hooks/useLoading"; +import { useShortcutStore, useWorkspaceStore } from "@/stores"; +import { getShortcutUpdateMask } from "@/stores/shortcut"; +import { Visibility } from "@/types/proto/api/v1/common"; +import { Shortcut } from "@/types/proto/api/v1/shortcut_service"; +import Icon from "./Icon"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; + +interface Props { + shortcutId?: number; + initialShortcut?: Partial; + onClose: () => void; + onConfirm?: () => void; +} + +interface State { + shortcutCreate: Shortcut; +} + +const CreateShortcutDrawer: React.FC = (props: Props) => { + const { onClose, onConfirm, shortcutId, initialShortcut } = props; + const { t } = useTranslation(); + const [state, setState] = useState({ + shortcutCreate: Shortcut.fromPartial({ + visibility: Visibility.WORKSPACE, + ogMetadata: { + title: "", + description: "", + image: "", + }, + ...initialShortcut, + }), + }); + const shortcutStore = useShortcutStore(); + const workspaceStore = useWorkspaceStore(); + const [showOpenGraphMetadata, setShowOpenGraphMetadata] = useState(false); + const shortcutList = shortcutStore.getShortcutList(); + const [tag, setTag] = useState(""); + const tagSuggestions = uniq(shortcutList.map((shortcut) => shortcut.tags).flat()); + const isCreating = isUndefined(shortcutId); + const loadingState = useLoading(!isCreating); + const requestState = useLoading(false); + + const setPartialState = (partialState: Partial) => { + setState({ + ...state, + ...partialState, + }); + }; + + useEffect(() => { + if (workspaceStore.setting.defaultVisibility !== Visibility.VISIBILITY_UNSPECIFIED) { + setPartialState({ + shortcutCreate: Object.assign(state.shortcutCreate, { + visibility: workspaceStore.setting.defaultVisibility, + }), + }); + } + }, []); + + useEffect(() => { + if (shortcutId) { + const shortcut = shortcutStore.getShortcutById(shortcutId); + if (shortcut) { + setState({ + ...state, + shortcutCreate: Object.assign(state.shortcutCreate, { + name: shortcut.name, + link: shortcut.link, + title: shortcut.title, + description: shortcut.description, + visibility: shortcut.visibility, + ogMetadata: shortcut.ogMetadata, + }), + }); + setTag(shortcut.tags.join(" ")); + loadingState.setFinish(); + } + } + }, [shortcutId]); + + if (loadingState.isLoading) { + return null; + } + + const handleNameInputChange = (e: React.ChangeEvent) => { + setPartialState({ + shortcutCreate: Object.assign(state.shortcutCreate, { + name: e.target.value.replace(/\s+/g, "-"), + }), + }); + }; + + const handleLinkInputChange = (e: React.ChangeEvent) => { + setPartialState({ + shortcutCreate: Object.assign(state.shortcutCreate, { + link: e.target.value, + }), + }); + }; + + const handleTitleInputChange = (e: React.ChangeEvent) => { + setPartialState({ + shortcutCreate: Object.assign(state.shortcutCreate, { + title: e.target.value, + }), + }); + }; + + const handleDescriptionInputChange = (e: React.ChangeEvent) => { + setPartialState({ + shortcutCreate: Object.assign(state.shortcutCreate, { + description: e.target.value, + }), + }); + }; + + const handleTagsInputChange = (e: React.ChangeEvent) => { + const text = e.target.value as string; + setTag(text); + }; + + const handleOpenGraphMetadataImageChange = (e: React.ChangeEvent) => { + setPartialState({ + shortcutCreate: Object.assign(state.shortcutCreate, { + ogMetadata: { + ...state.shortcutCreate.ogMetadata, + image: e.target.value, + }, + }), + }); + }; + + const handleOpenGraphMetadataTitleChange = (e: React.ChangeEvent) => { + setPartialState({ + shortcutCreate: Object.assign(state.shortcutCreate, { + ogMetadata: { + ...state.shortcutCreate.ogMetadata, + title: e.target.value, + }, + }), + }); + }; + + const handleOpenGraphMetadataDescriptionChange = (e: React.ChangeEvent) => { + setPartialState({ + shortcutCreate: Object.assign(state.shortcutCreate, { + ogMetadata: { + ...state.shortcutCreate.ogMetadata, + description: e.target.value, + }, + }), + }); + }; + + const handleTagSuggestionsClick = (suggestion: string) => { + if (tag === "") { + setTag(suggestion); + } else { + setTag(`${tag} ${suggestion}`); + } + }; + + const handleSaveBtnClick = async () => { + if (!state.shortcutCreate.name || !state.shortcutCreate.link) { + toast.error("Please fill in required fields."); + return; + } + + try { + requestState.setLoading(); + const tags = tag.split(" ").filter(Boolean); + if (shortcutId) { + const originShortcut = shortcutStore.getShortcutById(shortcutId); + const updatingShortcut = { + ...state.shortcutCreate, + id: shortcutId, + tags, + }; + await shortcutStore.updateShortcut(updatingShortcut, getShortcutUpdateMask(originShortcut, updatingShortcut)); + } else { + await shortcutStore.createShortcut({ + ...state.shortcutCreate, + tags, + }); + } + + requestState.setFinish(); + if (onConfirm) { + onConfirm(); + } else { + onClose(); + } + } catch (error: any) { + console.error(error); + toast.error(error.details || "An error occurred"); + requestState.setFinish(); + } + }; + + return ( + + + + {isCreating ? "Create Shortcut" : "Edit Shortcut"} + + {isCreating ? "Create a new shortcut" : "Edit your shortcut details"} + + +
+
+ +
+ s/ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {tagSuggestions.length > 0 && ( +
+ +
+ {tagSuggestions.map((tag) => ( + handleTagSuggestionsClick(tag)} + > + {tag} + + ))} +
+
+ )} +
+ +
+ + setPartialState({ + shortcutCreate: Object.assign(state.shortcutCreate, { + visibility: checked ? Visibility.PUBLIC : Visibility.WORKSPACE, + }), + }) + } + /> + +
+ + + +
+
setShowOpenGraphMetadata(!showOpenGraphMetadata)} + > + + Social media metadata + + + +
+ {showOpenGraphMetadata && ( +
+
+ + +
+
+ + +
+
+ +