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:
Linus Rath
2026-03-17 23:02:26 +01:00
parent 3186198fad
commit 6fe4a98b02
20 changed files with 1545 additions and 41 deletions
+149
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+149 -7
View File
@@ -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"
+355
View File
@@ -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} />
</>
);
}