feat: enhance settings page with tab icons and grouping

feat(files-settings): improve file settings preview and layout
feat(locales): update translations for tab groups and add preview labels
This commit is contained in:
Linus Rath
2026-03-16 02:10:29 +01:00
parent 716db8d003
commit 37b0bacccb
10 changed files with 355 additions and 57 deletions
+125 -38
View File
@@ -3,7 +3,26 @@
import { useState, useEffect } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
import { ArrowLeft, ChevronRight, LogOut, Settings as SettingsIcon } from 'lucide-react';
import {
ArrowLeft,
ChevronRight,
LogOut,
Settings as SettingsIcon,
Palette,
Mail,
User,
Shield,
UserPen,
PalmtreeIcon,
Calendar,
Filter,
FileText,
FolderOpen,
Tags,
HardDrive,
Wrench,
type LucideIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AppearanceSettings } from '@/components/settings/appearance-settings';
import { EmailSettings } from '@/components/settings/email-settings';
@@ -27,6 +46,32 @@ import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
interface TabDef {
id: Tab;
label: string;
icon: LucideIcon;
group: TabGroup;
}
const tabIcons: Record<Tab, LucideIcon> = {
appearance: Palette,
email: Mail,
account: User,
security: Shield,
identities: UserPen,
vacation: PalmtreeIcon,
calendar: Calendar,
filters: Filter,
templates: FileText,
folders: FolderOpen,
keywords: Tags,
files: HardDrive,
advanced: Wrench,
};
const tabGroupOrder: TabGroup[] = ['general', 'account', 'organization', 'apps', 'system'];
export default function SettingsPage() {
const router = useRouter();
@@ -69,22 +114,31 @@ export default function SettingsPage() {
const supportsSieve = client?.supportsSieve() ?? false;
const supportsFiles = client?.supportsFiles() ?? false;
const tabs: { id: Tab; label: string }[] = [
{ id: 'appearance', label: t('tabs.appearance') },
{ id: 'email', label: t('tabs.email') },
{ id: 'account', label: t('tabs.account') },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security') }] : []),
{ id: 'identities', label: t('tabs.identities') },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation') }] : []),
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar') }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters') }] : []),
{ id: 'templates', label: t('tabs.templates') },
{ id: 'folders', label: t('tabs.folders') },
{ id: 'keywords', label: t('tabs.keywords') },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files') }] : []),
{ id: 'advanced', label: t('tabs.advanced') },
const tabs: TabDef[] = [
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' },
{ id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' },
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []),
{ id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' },
...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []),
...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []),
{ id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' },
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
{ id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' },
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
];
// Group tabs by category
const groupedTabs = tabGroupOrder
.map((group) => ({
group,
label: t(`tab_groups.${group}`),
items: tabs.filter((tab) => tab.group === group),
}))
.filter((g) => g.items.length > 0);
const handleTabSelect = (tabId: Tab) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
@@ -167,15 +221,31 @@ export default function SettingsPage() {
{/* Tab list */}
<div className="flex-1 overflow-y-auto">
<div className="py-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabSelect(tab.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
>
<span>{tab.label}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
{groupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
<div className="px-5 pt-3 pb-1.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => handleTabSelect(tab.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-sm text-foreground hover:bg-muted transition-colors duration-150"
>
<span className="flex items-center gap-3">
<Icon className="w-4 h-4 text-muted-foreground" />
{tab.label}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
);
})}
</div>
))}
</div>
@@ -227,20 +297,37 @@ export default function SettingsPage() {
{/* Tabs */}
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150',
activeTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
{tab.label}
</button>
<div className="px-2 space-y-0.5">
{groupedTabs.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
activeTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<Icon className={cn(
'w-4 h-4 shrink-0',
activeTab === tab.id ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{tab.label}
</button>
);
})}
</div>
))}
</div>
</div>
+174 -3
View File
@@ -1,9 +1,173 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Home, ChevronRight, ChevronDown } from "lucide-react";
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
import { cn } from "@/lib/utils";
interface SampleFile {
name: string;
isFolder: boolean;
size: number;
modified: string;
hidden?: boolean;
thumbnailUrl?: string;
}
const SAMPLE_FILES: SampleFile[] = [
{ name: "Documents", isFolder: true, size: 0, modified: "2026-03-10" },
{ name: "Photos", isFolder: true, size: 0, modified: "2026-03-14" },
{ name: "report.pdf", isFolder: false, size: 245000, modified: "2026-03-15" },
{ name: "notes.md", isFolder: false, size: 1200, modified: "2026-03-12" },
{ name: "vacation.jpg", isFolder: false, size: 3400000, modified: "2026-03-08", thumbnailUrl: "/branding/Bulwark_Logo_Color.png" },
{ name: "song.mp3", isFolder: false, size: 5200000, modified: "2026-03-01" },
{ name: ".config", isFolder: false, size: 340, modified: "2026-02-20", hidden: true },
];
function getPreviewIcon(file: SampleFile, colored: boolean, size: "sm" | "lg") {
const cls = size === "sm" ? "w-4 h-4 flex-shrink-0" : "w-8 h-8 flex-shrink-0";
if (file.isFolder) {
return <Folder className={cn(cls, colored ? "text-blue-500" : "text-muted-foreground")} />;
}
const ext = file.name.split(".").pop()?.toLowerCase();
switch (ext) {
case "jpg": case "png": case "gif":
return <ImageIcon className={cn(cls, colored ? "text-emerald-500" : "text-muted-foreground")} />;
case "mp3": case "wav":
return <FileAudio className={cn(cls, colored ? "text-purple-500" : "text-muted-foreground")} />;
case "pdf":
return <FileText className={cn(cls, colored ? "text-red-600" : "text-muted-foreground")} />;
case "md": case "json": case "js": case "ts":
return <FileCode className={cn(cls, colored ? "text-yellow-600" : "text-muted-foreground")} />;
default:
return <File className={cn(cls, "text-muted-foreground")} />;
}
}
function formatSize(bytes: number): string {
if (bytes === 0) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
const sortedFiles = useMemo(() => {
let files = SAMPLE_FILES.filter((f) => {
if (!settings.showHiddenFiles && f.hidden) return false;
if (settings.folderLayout === "sidebar" && f.isFolder) return false;
return true;
});
files.sort((a, b) => {
// Folders first
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
let cmp = 0;
switch (settings.defaultSortKey) {
case "name": cmp = a.name.localeCompare(b.name); break;
case "size": cmp = a.size - b.size; break;
case "modified": cmp = a.modified.localeCompare(b.modified); break;
}
return settings.defaultSortDir === "desc" ? -cmp : cmp;
});
return files;
}, [settings.showHiddenFiles, settings.folderLayout, settings.defaultSortKey, settings.defaultSortDir]);
const listView = (
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-3 px-2 py-1 text-[10px] font-medium text-muted-foreground border-b border-border bg-muted/50">
<span className="flex-1 min-w-0">Name</span>
<span className="w-14 text-right">Size</span>
<span className="w-16 text-right">Modified</span>
</div>
{sortedFiles.map((file) => (
<div
key={file.name}
className={cn(
"flex items-center gap-2 px-2 py-1.5 border-b border-border last:border-b-0 transition-colors hover:bg-muted/50",
file.hidden && "opacity-50"
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "sm")
) : null}
<span className={cn("flex-1 min-w-0 truncate text-[11px]", file.isFolder && "font-medium")}>
{file.name}
</span>
<span className="w-14 text-right text-[10px] text-muted-foreground tabular-nums">
{formatSize(file.size)}
</span>
<span className="w-16 text-right text-[10px] text-muted-foreground tabular-nums">
{file.modified.slice(5)}
</span>
</div>
))}
</div>
);
const gridView = (
<div className="flex-1 min-w-0 p-2">
<div className="grid grid-cols-[repeat(auto-fill,minmax(4.5rem,1fr))] gap-1.5">
{sortedFiles.map((file) => (
<div
key={file.name}
className={cn(
"flex flex-col items-center gap-1 p-2 rounded-md transition-colors hover:bg-muted/50",
file.hidden && "opacity-50"
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "lg")
) : (
<div className="w-8 h-8" />
)}
<span className={cn("text-[9px] truncate w-full text-center", file.isFolder && "font-medium")}>
{file.name}
</span>
</div>
))}
</div>
</div>
);
const sidebar = settings.folderLayout === "sidebar" && (
<div className="w-24 border-r border-border bg-muted/30 py-1.5 flex-shrink-0">
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-medium text-foreground">
<Home className="w-3 h-3 flex-shrink-0" />
<span className="truncate">Files</span>
</div>
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] text-foreground bg-accent rounded-sm mx-1">
<ChevronDown className="w-2.5 h-2.5 flex-shrink-0" />
<FolderOpen className="w-3 h-3 flex-shrink-0 text-blue-500" />
<span className="truncate">Documents</span>
</div>
<div className="flex items-center gap-1 px-2 py-0.5 text-[10px] text-muted-foreground" style={{ paddingLeft: "1.25rem" }}>
<ChevronRight className="w-2.5 h-2.5 flex-shrink-0" />
<Folder className="w-3 h-3 flex-shrink-0 text-blue-500" />
<span className="truncate">Photos</span>
</div>
</div>
);
return (
<div className="mt-4 rounded-lg border border-border overflow-hidden bg-background text-xs select-none">
<div className="flex" style={{ minHeight: "10rem" }}>
{sidebar}
{settings.defaultViewMode === "grid" ? gridView : listView}
</div>
</div>
);
}
export function FilesSettingsComponent() {
const t = useTranslations("settings.files");
@@ -29,8 +193,14 @@ export function FilesSettingsComponent() {
}, []);
return (
<div className="space-y-8">
<SettingsSection title={t("display.title")} description={t("display.description")}>
<div>
<div className="sticky top-0 z-10 bg-background pb-4 -mx-4 px-4 -mt-4 pt-4 lg:-mx-6 lg:px-6 lg:-mt-6 lg:pt-6 border-b border-border mb-6">
<p className="text-sm font-medium text-foreground mb-1">{t("preview.label")}</p>
<FilesSettingsPreview settings={settings} />
</div>
<div className="space-y-8">
<SettingsSection title={t("display.title")} description={t("display.description")}>
<SettingItem label={t("folder_layout.label")} description={t("folder_layout.description")}>
<RadioGroup
value={settings.folderLayout}
@@ -104,6 +274,7 @@ export function FilesSettingsComponent() {
/>
</SettingItem>
</SettingsSection>
</div>
</div>
);
}
+7 -2
View File
@@ -504,8 +504,10 @@
},
"tab_groups": {
"general": "Allgemein",
"account": "Konto",
"organization": "Organisation"
"account": "Konto & Identität",
"organization": "E-Mail-Organisation",
"apps": "Apps",
"system": "System"
},
"appearance": {
"title": "Darstellung",
@@ -1112,6 +1114,9 @@
"show_hidden": {
"label": "Versteckte Dateien anzeigen",
"description": "Dateien und Ordner anzeigen, die mit einem Punkt beginnen"
},
"preview": {
"label": "Vorschau"
}
}
},
+7 -2
View File
@@ -504,8 +504,10 @@
},
"tab_groups": {
"general": "General",
"account": "Account",
"organization": "Organization"
"account": "Account & Identity",
"organization": "Mail Organization",
"apps": "Apps",
"system": "System"
},
"appearance": {
"title": "Appearance",
@@ -1118,6 +1120,9 @@
"show_hidden": {
"label": "Show Hidden Files",
"description": "Display files and folders that start with a dot"
},
"preview": {
"label": "Preview"
}
}
},
+7 -2
View File
@@ -504,8 +504,10 @@
},
"tab_groups": {
"general": "General",
"account": "Cuenta",
"organization": "Organización"
"account": "Cuenta e identidad",
"organization": "Organización del correo",
"apps": "Aplicaciones",
"system": "Sistema"
},
"appearance": {
"title": "Apariencia",
@@ -1112,6 +1114,9 @@
"show_hidden": {
"label": "Mostrar archivos ocultos",
"description": "Mostrar archivos y carpetas que comienzan con un punto"
},
"preview": {
"label": "Vista previa"
}
}
},
+7 -2
View File
@@ -504,8 +504,10 @@
},
"tab_groups": {
"general": "Général",
"account": "Compte",
"organization": "Organisation"
"account": "Compte & Identité",
"organization": "Organisation des e-mails",
"apps": "Applications",
"system": "Système"
},
"appearance": {
"title": "Apparence",
@@ -1112,6 +1114,9 @@
"show_hidden": {
"label": "Afficher les fichiers cachés",
"description": "Afficher les fichiers et dossiers commençant par un point"
},
"preview": {
"label": "Aperçu"
}
}
},
+7 -2
View File
@@ -504,8 +504,10 @@
},
"tab_groups": {
"general": "Generale",
"account": "Account",
"organization": "Organizzazione"
"account": "Account e identità",
"organization": "Organizzazione e-mail",
"apps": "Applicazioni",
"system": "Sistema"
},
"appearance": {
"title": "Aspetto",
@@ -1112,6 +1114,9 @@
"show_hidden": {
"label": "Mostra file nascosti",
"description": "Visualizza file e cartelle che iniziano con un punto"
},
"preview": {
"label": "Anteprima"
}
}
},
+7 -2
View File
@@ -504,8 +504,10 @@
},
"tab_groups": {
"general": "一般",
"account": "アカウント",
"organization": "整理"
"account": "アカウントと身元",
"organization": "メール整理",
"apps": "アプリ",
"system": "システム"
},
"appearance": {
"title": "外観",
@@ -1112,6 +1114,9 @@
"show_hidden": {
"label": "隠しファイルを表示",
"description": "ドットで始まるファイルとフォルダーを表示します"
},
"preview": {
"label": "プレビュー"
}
}
},
+7 -2
View File
@@ -504,8 +504,10 @@
},
"tab_groups": {
"general": "Algemeen",
"account": "Account",
"organization": "Organisatie"
"account": "Account & identiteit",
"organization": "E-mailorganisatie",
"apps": "Apps",
"system": "Systeem"
},
"appearance": {
"title": "Uiterlijk",
@@ -1112,6 +1114,9 @@
"show_hidden": {
"label": "Verborgen bestanden tonen",
"description": "Bestanden en mappen weergeven die beginnen met een punt"
},
"preview": {
"label": "Voorbeeld"
}
}
},
+7 -2
View File
@@ -504,8 +504,10 @@
},
"tab_groups": {
"general": "Geral",
"account": "Conta",
"organization": "Organização"
"account": "Conta e identidade",
"organization": "Organização de e-mail",
"apps": "Aplicativos",
"system": "Sistema"
},
"appearance": {
"title": "Aparência",
@@ -1112,6 +1114,9 @@
"show_hidden": {
"label": "Mostrar arquivos ocultos",
"description": "Exibir arquivos e pastas que começam com um ponto"
},
"preview": {
"label": "Visualização"
}
}
},