feat: add sidebar apps management feature
- Implemented sidebar apps functionality including adding, editing, and deleting apps. - Created a modal for managing sidebar apps with forms for inputting app details. - Added icon picker component for selecting app icons. - Introduced inline app view for displaying apps within the sidebar. - Updated translations for new sidebar apps feature in Dutch and Portuguese. - Enhanced settings store to manage sidebar apps state. - Added hooks for managing sidebar apps state and modal visibility.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
// Curated list of commonly useful icons, organized by category
|
||||
const POPULAR_ICONS = [
|
||||
// Communication
|
||||
'Globe', 'Rss', 'Radio', 'Podcast', 'MessageCircle', 'MessageSquare', 'MessagesSquare',
|
||||
'Phone', 'Video', 'Webcam', 'Headphones', 'Mic',
|
||||
// Productivity
|
||||
'FileText', 'FileSpreadsheet', 'Notebook', 'BookOpen', 'ClipboardList',
|
||||
'ListTodo', 'CheckSquare', 'SquareKanban', 'Kanban', 'Trello',
|
||||
'PenLine', 'Pencil', 'Edit', 'NotebookPen',
|
||||
// Dev / Tech
|
||||
'Code', 'Terminal', 'Braces', 'Bug', 'Database', 'Server', 'Cpu',
|
||||
'HardDrive', 'Monitor', 'Laptop', 'Smartphone', 'Tablet',
|
||||
'Wifi', 'Cloud', 'CloudDownload', 'CloudUpload',
|
||||
// Social / People
|
||||
'Users', 'UserPlus', 'UserCircle', 'Contact', 'PersonStanding',
|
||||
'Heart', 'ThumbsUp', 'Star', 'Award', 'Trophy', 'Crown',
|
||||
// Media
|
||||
'Image', 'Camera', 'Film', 'Music', 'Play', 'Tv', 'Youtube', 'Clapperboard',
|
||||
'Palette', 'Paintbrush', 'Brush',
|
||||
// Navigation / Location
|
||||
'Map', 'MapPin', 'Navigation', 'Compass', 'Home', 'Building', 'Building2',
|
||||
'Landmark', 'Store', 'Warehouse',
|
||||
// Finance
|
||||
'DollarSign', 'Euro', 'CreditCard', 'Wallet', 'Receipt', 'PiggyBank',
|
||||
'TrendingUp', 'BarChart', 'BarChart3', 'LineChart', 'PieChart',
|
||||
// Security
|
||||
'Shield', 'ShieldCheck', 'Lock', 'Unlock', 'Key', 'Fingerprint', 'Eye',
|
||||
// Science / Health
|
||||
'Beaker', 'Atom', 'Dna', 'Microscope', 'Stethoscope', 'HeartPulse', 'Pill',
|
||||
'Syringe', 'Thermometer',
|
||||
// Nature
|
||||
'Sun', 'Moon', 'CloudSun', 'Snowflake', 'Zap', 'Flame',
|
||||
'TreePine', 'Flower', 'Leaf', 'Mountain', 'Waves',
|
||||
// Tools
|
||||
'Wrench', 'Hammer', 'Scissors', 'Ruler', 'Magnet',
|
||||
'Package', 'Gift', 'Box', 'Archive',
|
||||
// Transport
|
||||
'Car', 'Bike', 'Bus', 'Train', 'Plane', 'Ship', 'Rocket',
|
||||
// Food
|
||||
'Coffee', 'Wine', 'Beer', 'Pizza', 'Apple', 'Cake', 'CookingPot',
|
||||
// Misc
|
||||
'Gamepad2', 'Dice5', 'Puzzle', 'Sparkles', 'Wand2', 'Bot', 'BrainCircuit',
|
||||
'Lightbulb', 'Bookmark', 'Flag', 'Bell', 'Clock', 'Timer',
|
||||
'Link', 'ExternalLink', 'QrCode', 'Scan', 'LayoutGrid', 'Layers',
|
||||
'Aperture', 'CircleDot', 'Target', 'Crosshair',
|
||||
];
|
||||
|
||||
interface IconPickerProps {
|
||||
value: string;
|
||||
onChange: (iconName: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconPicker({ value, onChange, className }: IconPickerProps) {
|
||||
const t = useTranslations('sidebar_apps');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get all available icon names
|
||||
const allIconNames = useMemo(() => {
|
||||
return Object.keys(lucideIcons).filter(
|
||||
k => /^[A-Z]/.test(k) && k !== 'createLucideIcon' && k !== 'Icon'
|
||||
).sort();
|
||||
}, []);
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
const source = showAll ? allIconNames : POPULAR_ICONS.filter(name => name in lucideIcons);
|
||||
if (!search.trim()) return source;
|
||||
const q = search.toLowerCase();
|
||||
return source.filter(name => name.toLowerCase().includes(q));
|
||||
}, [search, showAll, allIconNames]);
|
||||
|
||||
const renderIcon = useCallback((name: string) => {
|
||||
const IconComponent = lucideIcons[name as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
if (!IconComponent) return null;
|
||||
return <IconComponent className="w-5 h-5" />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('search_icons')}
|
||||
className="pl-8 h-8 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className={cn(
|
||||
'text-xs px-2 py-1 rounded-md border transition-colors whitespace-nowrap',
|
||||
showAll
|
||||
? 'bg-primary/10 text-primary border-primary/30'
|
||||
: 'bg-muted text-muted-foreground border-border hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{showAll ? t('show_popular') : t('show_all')}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-8 gap-1 max-h-[200px] overflow-y-auto p-1 border rounded-md bg-muted/30"
|
||||
>
|
||||
{filteredIcons.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => onChange(name)}
|
||||
title={name}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-md transition-colors',
|
||||
value === name
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{renderIcon(name)}
|
||||
</button>
|
||||
))}
|
||||
{filteredIcons.length === 0 && (
|
||||
<p className="col-span-8 py-4 text-center text-xs text-muted-foreground">
|
||||
{t('no_icons_found')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { InlineAppState } from '@/hooks/use-sidebar-apps';
|
||||
|
||||
interface InlineAppViewProps {
|
||||
apps: InlineAppState[];
|
||||
activeAppId: string;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineAppView({ apps, activeAppId, onClose, className }: InlineAppViewProps) {
|
||||
const activeApp = apps.find((a) => a.id === activeAppId);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-background', className)}>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
|
||||
<h3 className="text-sm font-medium truncate">{activeApp?.name}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Iframes - active one visible, rest hidden but alive */}
|
||||
<div className="flex-1 relative">
|
||||
{apps.map((app) => (
|
||||
<iframe
|
||||
key={app.id}
|
||||
src={app.url}
|
||||
title={app.name}
|
||||
className={cn(
|
||||
'absolute inset-0 w-full h-full border-0',
|
||||
app.id !== activeAppId && 'hidden'
|
||||
)}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard } from "lucide-react";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
|
||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||
import { usePathname, Link } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
|
||||
interface NavItem {
|
||||
@@ -27,6 +29,10 @@ interface NavigationRailProps {
|
||||
isPushConnected?: boolean;
|
||||
onLogout?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
onManageApps?: () => void;
|
||||
onInlineApp?: (appId: string, url: string, name: string) => void;
|
||||
onCloseInlineApp?: () => void;
|
||||
activeAppId?: string | null;
|
||||
}
|
||||
|
||||
function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) {
|
||||
@@ -138,12 +144,17 @@ export function NavigationRail({
|
||||
isPushConnected,
|
||||
onLogout,
|
||||
onShowShortcuts,
|
||||
onManageApps,
|
||||
onInlineApp,
|
||||
onCloseInlineApp,
|
||||
activeAppId,
|
||||
}: NavigationRailProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const pathname = usePathname();
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { mailboxes } = useEmailStore();
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
const sidebarApps = useSettingsStore((s) => s.sidebarApps);
|
||||
const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
@@ -151,12 +162,14 @@ export function NavigationRail({
|
||||
{ id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar },
|
||||
{ id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts" },
|
||||
{ id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: supportsWebDAV === false },
|
||||
{ id: "settings", icon: Settings, labelKey: "settings", href: "/settings" },
|
||||
];
|
||||
|
||||
const isSettingsActive = !activeAppId && pathname.startsWith("/settings");
|
||||
|
||||
const visibleItems = navItems.filter((item) => !item.hidden);
|
||||
|
||||
const getIsActive = (href: string) => {
|
||||
if (activeAppId) return false;
|
||||
if (href === "/") {
|
||||
return pathname === "/" || pathname === "";
|
||||
}
|
||||
@@ -177,6 +190,7 @@ export function NavigationRail({
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
|
||||
"transition-colors duration-150",
|
||||
@@ -201,6 +215,52 @@ export function NavigationRail({
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom sidebar apps */}
|
||||
{sidebarApps.map((app) => {
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
const isActive = activeAppId === app.id;
|
||||
return (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onCloseInlineApp?.();
|
||||
} else if (app.openMode === 'tab') {
|
||||
window.open(app.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
onInlineApp?.(app.id, app.url, app.name);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px]",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
{AppIcon ? <AppIcon className="w-5 h-5" /> : null}
|
||||
{isActive && (
|
||||
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight truncate max-w-[64px]">{app.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Manage apps button */}
|
||||
{onManageApps && (
|
||||
<button
|
||||
onClick={onManageApps}
|
||||
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span className="text-[10px] font-medium leading-tight">{t("add_app")}</span>
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -230,6 +290,7 @@ export function NavigationRail({
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||
collapsed
|
||||
@@ -257,13 +318,90 @@ export function NavigationRail({
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom sidebar apps */}
|
||||
{sidebarApps.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"border-t",
|
||||
collapsed ? "w-8 mx-auto my-1 pt-1" : "mx-2 my-0.5 pt-0.5"
|
||||
)}
|
||||
style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }}
|
||||
/>
|
||||
)}
|
||||
{sidebarApps.map((app) => {
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
const isActive = activeAppId === app.id;
|
||||
return (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onCloseInlineApp?.();
|
||||
} else if (app.openMode === 'tab') {
|
||||
window.open(app.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
onInlineApp?.(app.id, app.url, app.name);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||
collapsed
|
||||
? "justify-center w-10 h-10"
|
||||
: "px-2.5 text-sm",
|
||||
"max-lg:min-h-[44px]",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={collapsed ? app.name : undefined}
|
||||
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
>
|
||||
{AppIcon ? <AppIcon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} /> : null}
|
||||
{!collapsed && <span className="truncate">{app.name}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Manage apps button */}
|
||||
{onManageApps && (
|
||||
<button
|
||||
onClick={onManageApps}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||
collapsed
|
||||
? "justify-center w-10 h-10"
|
||||
: "px-2.5 text-sm",
|
||||
"max-lg:min-h-[44px]",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
title={collapsed ? t("add_app") : undefined}
|
||||
style={collapsed ? undefined : { paddingBlock: 'var(--density-sidebar-py)' }}
|
||||
>
|
||||
<Plus className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
{!collapsed && <span className="truncate">{t("add_app")}</span>}
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Footer: Storage Quota + Sign Out + Push Status */}
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1 border-t border-border pt-2">
|
||||
{quota && quota.total > 0 && (
|
||||
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
|
||||
)}
|
||||
{/* Footer: Settings + Help + Storage Quota + Sign Out + Push Status */}
|
||||
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
|
||||
isSettingsActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={t("settings")}
|
||||
aria-current={isSettingsActive ? "page" : undefined}
|
||||
>
|
||||
<Settings className="w-[18px] h-[18px]" />
|
||||
</Link>
|
||||
|
||||
<div className="w-8 border-t" style={{ borderColor: 'rgba(128, 128, 128, 0.3)' }} />
|
||||
|
||||
{onShowShortcuts && (
|
||||
<button
|
||||
@@ -275,6 +413,10 @@ export function NavigationRail({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{quota && quota.total > 0 && (
|
||||
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
|
||||
)}
|
||||
|
||||
{isPushConnected != null && (
|
||||
<span
|
||||
className="relative group"
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { X, Plus, Pencil, Trash2, GripVertical, ExternalLink, PanelRight } from 'lucide-react';
|
||||
import { icons as lucideIcons, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { IconPicker } from './icon-picker';
|
||||
import { useSettingsStore, type SidebarApp } from '@/stores/settings-store';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
|
||||
interface SidebarAppFormData {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
openMode: 'tab' | 'inline';
|
||||
}
|
||||
|
||||
function SidebarAppForm({
|
||||
app,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
app?: SidebarApp;
|
||||
onSave: (data: SidebarAppFormData) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const t = useTranslations('sidebar_apps');
|
||||
const isEditing = !!app;
|
||||
|
||||
const [formData, setFormData] = useState<SidebarAppFormData>({
|
||||
name: app?.name || '',
|
||||
url: app?.url || '',
|
||||
icon: app?.icon || 'Globe',
|
||||
openMode: app?.openMode || 'tab',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = t('name_required');
|
||||
}
|
||||
if (!formData.url.trim()) {
|
||||
newErrors.url = t('url_required');
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(formData.url);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
newErrors.url = t('url_invalid');
|
||||
}
|
||||
} catch {
|
||||
newErrors.url = t('url_invalid');
|
||||
}
|
||||
}
|
||||
if (!formData.icon) {
|
||||
newErrors.icon = t('icon_required');
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
const SelectedIcon = formData.icon
|
||||
? (lucideIcons[formData.icon as keyof typeof lucideIcons] as LucideIcon | undefined)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="app-name" className="block text-sm font-medium mb-1">
|
||||
{t('name_label')} <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="app-name"
|
||||
type="text"
|
||||
maxLength={50}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t('name_placeholder')}
|
||||
className={errors.name ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label htmlFor="app-url" className="block text-sm font-medium mb-1">
|
||||
{t('url_label')} <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="app-url"
|
||||
type="url"
|
||||
maxLength={2048}
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className={errors.url ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.url && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.url}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open Mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">{t('open_mode_label')}</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, openMode: 'tab' })}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors flex-1',
|
||||
formData.openMode === 'tab'
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('open_new_tab')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, openMode: 'inline' })}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors flex-1',
|
||||
formData.openMode === 'inline'
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<PanelRight className="w-4 h-4" />
|
||||
{t('open_inline')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon Picker */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t('icon_label')} <span className="text-destructive">*</span>
|
||||
{SelectedIcon && (
|
||||
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
|
||||
— <SelectedIcon className="w-4 h-4" /> {formData.icon}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<IconPicker
|
||||
value={formData.icon}
|
||||
onChange={(icon) => setFormData({ ...formData, icon })}
|
||||
/>
|
||||
{errors.icon && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.icon}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{isEditing ? t('update') : t('add')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarAppsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
|
||||
const t = useTranslations('sidebar_apps');
|
||||
const { sidebarApps, addSidebarApp, updateSidebarApp, removeSidebarApp } = useSettingsStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: () => {
|
||||
if (isCreating || editingId) {
|
||||
setIsCreating(false);
|
||||
setEditingId(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const handleCreate = useCallback((data: SidebarAppFormData) => {
|
||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
addSidebarApp({ id, ...data });
|
||||
setIsCreating(false);
|
||||
}, [addSidebarApp]);
|
||||
|
||||
const handleUpdate = useCallback((id: string, data: SidebarAppFormData) => {
|
||||
updateSidebarApp(id, data);
|
||||
setEditingId(null);
|
||||
}, [updateSidebarApp]);
|
||||
|
||||
const handleDelete = useCallback(async (app: SidebarApp) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t('delete_confirm_title'),
|
||||
message: t('delete_confirm', { name: app.name }),
|
||||
confirmText: t('delete'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
removeSidebarApp(app.id);
|
||||
}, [removeSidebarApp, confirmDialog, t]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sidebar-apps-modal-title"
|
||||
className={cn(
|
||||
'bg-background border border-border rounded-lg shadow-xl',
|
||||
'w-full max-w-2xl max-h-[90vh] overflow-hidden',
|
||||
'animate-in zoom-in-95 duration-200'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h2 id="sidebar-apps-modal-title" className="text-lg font-semibold text-foreground">
|
||||
{t('modal_title')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
{/* Create form */}
|
||||
{isCreating && (
|
||||
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('add_new')}</h3>
|
||||
<SidebarAppForm
|
||||
onSave={handleCreate}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add button */}
|
||||
{!isCreating && !editingId && (
|
||||
<Button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="mb-6 w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('add_new')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Apps list */}
|
||||
<div className="space-y-3">
|
||||
{sidebarApps.map((app) => {
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
|
||||
if (editingId === app.id) {
|
||||
return (
|
||||
<div key={app.id} className="p-4 border border-border rounded-lg bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">{t('edit_app')}</h3>
|
||||
<SidebarAppForm
|
||||
app={app}
|
||||
onSave={(data) => handleUpdate(app.id, data)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
className="flex items-center gap-3 p-3 border border-border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-md bg-muted">
|
||||
{AppIcon ? <AppIcon className="w-5 h-5 text-muted-foreground" /> : null}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{app.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{app.url}</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[10px] px-1.5 py-0.5 rounded-full font-medium',
|
||||
app.openMode === 'inline'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
|
||||
)}>
|
||||
{app.openMode === 'inline' ? t('inline_badge') : t('tab_badge')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingId(app.id)}
|
||||
disabled={!!editingId || isCreating}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(app)}
|
||||
disabled={!!editingId || isCreating}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{sidebarApps.length === 0 && !isCreating && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Plus className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">{t('no_apps')}</p>
|
||||
<p className="text-xs mt-1">{t('no_apps_hint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Pencil, Trash2, ExternalLink, PanelRight, GripVertical } from "lucide-react";
|
||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from "./settings-section";
|
||||
import { IconPicker } from "@/components/layout/icon-picker";
|
||||
import { useSettingsStore, type SidebarApp } from "@/stores/settings-store";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarAppFormData {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
openMode: "tab" | "inline";
|
||||
}
|
||||
|
||||
function AppForm({
|
||||
app,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
app?: SidebarApp;
|
||||
onSave: (data: SidebarAppFormData) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const t = useTranslations("sidebar_apps");
|
||||
const isEditing = !!app;
|
||||
|
||||
const [formData, setFormData] = useState<SidebarAppFormData>({
|
||||
name: app?.name || "",
|
||||
url: app?.url || "",
|
||||
icon: app?.icon || "Globe",
|
||||
openMode: app?.openMode || "tab",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = t("name_required");
|
||||
}
|
||||
if (!formData.url.trim()) {
|
||||
newErrors.url = t("url_required");
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(formData.url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
newErrors.url = t("url_invalid");
|
||||
}
|
||||
} catch {
|
||||
newErrors.url = t("url_invalid");
|
||||
}
|
||||
}
|
||||
if (!formData.icon) {
|
||||
newErrors.icon = t("icon_required");
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
const SelectedIcon = formData.icon
|
||||
? (lucideIcons[formData.icon as keyof typeof lucideIcons] as LucideIcon | undefined)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-4 border border-border rounded-lg bg-secondary/30">
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("name_label")}</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t("name_placeholder")}
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-destructive mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("url_label")}</label>
|
||||
<Input
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.url && <p className="text-xs text-destructive mt-1">{errors.url}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1">{t("icon_label")}</label>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{SelectedIcon && (
|
||||
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center">
|
||||
<SelectedIcon className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">{formData.icon}</span>
|
||||
</div>
|
||||
<IconPicker value={formData.icon} onChange={(icon) => setFormData({ ...formData, icon })} />
|
||||
{errors.icon && <p className="text-xs text-destructive mt-1">{errors.icon}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">{t("open_mode_label")}</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, openMode: "tab" })}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm border transition-colors",
|
||||
formData.openMode === "tab"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("open_new_tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, openMode: "inline" })}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm border transition-colors",
|
||||
formData.openMode === "inline"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<PanelRight className="w-4 h-4" />
|
||||
{t("open_inline")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm">
|
||||
{isEditing ? t("update") : t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarAppsSettings() {
|
||||
const t = useTranslations("settings.sidebar_apps");
|
||||
const tApps = useTranslations("sidebar_apps");
|
||||
const { sidebarApps, keepAppsLoaded, addSidebarApp, updateSidebarApp, removeSidebarApp, updateSetting } = useSettingsStore();
|
||||
const [editingApp, setEditingApp] = useState<string | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const handleAdd = useCallback((data: SidebarAppFormData) => {
|
||||
const id = `app-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
addSidebarApp({ id, ...data });
|
||||
setShowAddForm(false);
|
||||
}, [addSidebarApp]);
|
||||
|
||||
const handleUpdate = useCallback((id: string, data: SidebarAppFormData) => {
|
||||
updateSidebarApp(id, data);
|
||||
setEditingApp(null);
|
||||
}, [updateSidebarApp]);
|
||||
|
||||
const handleDelete = useCallback(async (app: SidebarApp) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: tApps("delete_confirm_title"),
|
||||
message: tApps("delete_confirm", { name: app.name }),
|
||||
confirmText: tApps("delete"),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
removeSidebarApp(app.id);
|
||||
}, [confirmDialog, tApps, removeSidebarApp]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
<SettingItem label={t("keep_loaded")} description={t("keep_loaded_description")}>
|
||||
<ToggleSwitch
|
||||
checked={keepAppsLoaded}
|
||||
onChange={(v) => updateSetting("keepAppsLoaded", v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("manage_title")} description={t("manage_description")}>
|
||||
<div className="space-y-3">
|
||||
{sidebarApps.length === 0 && !showAddForm && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{tApps("no_apps_hint")}</p>
|
||||
)}
|
||||
|
||||
{sidebarApps.map((app) => {
|
||||
if (editingApp === app.id) {
|
||||
return (
|
||||
<AppForm
|
||||
key={app.id}
|
||||
app={app}
|
||||
onSave={(data) => handleUpdate(app.id, data)}
|
||||
onCancel={() => setEditingApp(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const AppIcon = lucideIcons[app.icon as keyof typeof lucideIcons] as LucideIcon | undefined;
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground/50 flex-shrink-0" />
|
||||
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center flex-shrink-0">
|
||||
{AppIcon ? <AppIcon className="w-4 h-4" /> : null}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{app.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{app.url}</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0",
|
||||
app.openMode === "inline"
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{app.openMode === "inline" ? tApps("inline_badge") : tApps("tab_badge")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditingApp(app.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(app)}
|
||||
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{showAddForm && (
|
||||
<AppForm
|
||||
onSave={handleAdd}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showAddForm && !editingApp && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{tApps("add_new")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user