Initial commit: Outlook-competitor desktop mail client
Tauri v2 (Rust backend, React/TypeScript frontend). IMAP/POP3/SMTP core, Google OAuth (mail/calendar/contacts sync), CalDAV, local calendar with recurrence, rules engine, contacts, tasks, unified inbox, context menus, drag & drop, snooze, templates, and more.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
export function parseAddressList(raw: string): string[] {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => {
|
||||
const match = s.match(/<([^>]+)>/)
|
||||
return (match ? match[1] : s).trim().toLowerCase()
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type {
|
||||
Account,
|
||||
CaldavAccount,
|
||||
ComposeMessage,
|
||||
Contact,
|
||||
ContactGroup,
|
||||
DraftPayload,
|
||||
Event,
|
||||
Folder,
|
||||
InviteInfo,
|
||||
InviteResponse,
|
||||
MessageFull,
|
||||
MessageSummary,
|
||||
NewAccount,
|
||||
NewContact,
|
||||
NewContactGroup,
|
||||
NewEvent,
|
||||
NewCaldavAccount,
|
||||
NewRule,
|
||||
NewTask,
|
||||
NewTemplate,
|
||||
NewVacationSettings,
|
||||
Rule,
|
||||
SnoozedMessage,
|
||||
Task,
|
||||
Template,
|
||||
VacationSettings,
|
||||
} from '../types'
|
||||
|
||||
export const api = {
|
||||
addAccount: (newAccount: NewAccount) => invoke<Account>('add_account', { newAccount }),
|
||||
listAccounts: () => invoke<Account[]>('list_accounts'),
|
||||
deleteAccount: (accountId: string) => invoke<void>('delete_account', { accountId }),
|
||||
updateAccount: (account: Account, newPassword?: string) =>
|
||||
invoke<void>('update_account', { account, newPassword: newPassword || undefined }),
|
||||
listFolders: (accountId: string) => invoke<Folder[]>('list_folders', { accountId }),
|
||||
createFolder: (accountId: string, name: string) => invoke<void>('create_folder', { accountId, name }),
|
||||
renameFolder: (accountId: string, from: string, to: string) => invoke<void>('rename_folder', { accountId, from, to }),
|
||||
deleteFolder: (accountId: string, name: string) => invoke<void>('delete_folder', { accountId, name }),
|
||||
markFolderRead: (accountId: string, folder: string) => invoke<number>('mark_folder_read', { accountId, folder }),
|
||||
applyRulesToFolder: (accountId: string, folder: string) => invoke<number>('apply_rules_to_folder', { accountId, folder }),
|
||||
syncMessages: (accountId: string, folder: string) =>
|
||||
invoke<MessageSummary[]>('sync_messages', { accountId, folder }),
|
||||
listCachedMessages: (accountId: string, folder: string) =>
|
||||
invoke<MessageSummary[]>('list_cached_messages', { accountId, folder }),
|
||||
getMessage: (accountId: string, folder: string, uid: number) =>
|
||||
invoke<MessageFull>('get_message', { accountId, folder, uid }),
|
||||
sendMessage: (message: ComposeMessage) => invoke<void>('send_message', { message }),
|
||||
sendMessageAt: (message: ComposeMessage, sendAtTs: number) => invoke<string>('send_message_at', { message, sendAtTs }),
|
||||
cancelScheduledSend: (id: string) => invoke<boolean>('cancel_scheduled_send', { id }),
|
||||
markRead: (accountId: string, folder: string, uid: number, isRead: boolean) =>
|
||||
invoke<void>('mark_read', { accountId, folder, uid, isRead }),
|
||||
deleteMessage: (accountId: string, folder: string, uid: number) =>
|
||||
invoke<void>('delete_message', { accountId, folder, uid }),
|
||||
searchMessages: (accountId: string, query: string) =>
|
||||
invoke<MessageSummary[]>('search_messages', { accountId, query }),
|
||||
searchAllMessages: (query: string) => invoke<MessageSummary[]>('search_all_messages', { query }),
|
||||
setMessageCategory: (accountId: string, folder: string, uid: number, category: string | null) =>
|
||||
invoke<void>('set_message_category', { accountId, folder, uid, category }),
|
||||
toggleFlag: (accountId: string, folder: string, uid: number, flagged: boolean) =>
|
||||
invoke<void>('toggle_flag', { accountId, folder, uid, flagged }),
|
||||
moveMessage: (accountId: string, folder: string, uid: number, targetFolder: string) =>
|
||||
invoke<void>('move_message', { accountId, folder, uid, targetFolder }),
|
||||
listContacts: () => invoke<Contact[]>('list_contacts'),
|
||||
searchContacts: (query: string) => invoke<Contact[]>('search_contacts', { query }),
|
||||
addContact: (newContact: NewContact) => invoke<Contact>('add_contact', { newContact }),
|
||||
updateContact: (contact: Contact) => invoke<void>('update_contact', { contact }),
|
||||
deleteContact: (contactId: string) => invoke<void>('delete_contact', { contactId }),
|
||||
toggleContactFavorite: (contactId: string, favorite: boolean) =>
|
||||
invoke<void>('toggle_contact_favorite', { contactId, favorite }),
|
||||
exportContactsVcard: (path: string) => invoke<number>('export_contacts_vcard', { path }),
|
||||
importContactsVcard: (path: string) => invoke<number>('import_contacts_vcard', { path }),
|
||||
exportMessageEml: (accountId: string, folder: string, uid: number, path: string) =>
|
||||
invoke<void>('export_message_eml', { accountId, folder, uid, path }),
|
||||
exportFolderMbox: (accountId: string, folder: string, path: string) =>
|
||||
invoke<number>('export_folder_mbox', { accountId, folder, path }),
|
||||
exportFullBackup: (dir: string) => invoke<number>('export_full_backup', { dir }),
|
||||
importEmlFile: (accountId: string, folder: string, path: string) =>
|
||||
invoke<void>('import_eml_file', { accountId, folder, path }),
|
||||
importMboxFile: (accountId: string, folder: string, path: string) =>
|
||||
invoke<number>('import_mbox_file', { accountId, folder, path }),
|
||||
saveDraft: (uid: number | null, message: ComposeMessage) =>
|
||||
invoke<number>('save_draft', { uid, message }),
|
||||
loadDraft: (uid: number) => invoke<DraftPayload>('load_draft', { uid }),
|
||||
googleOAuthAddAccount: () => invoke<Account>('google_oauth_add_account'),
|
||||
listRules: () => invoke<Rule[]>('list_rules'),
|
||||
createRule: (newRule: NewRule) => invoke<Rule>('create_rule', { newRule }),
|
||||
setRuleEnabled: (ruleId: string, enabled: boolean) => invoke<void>('set_rule_enabled', { ruleId, enabled }),
|
||||
deleteRule: (ruleId: string) => invoke<void>('delete_rule', { ruleId }),
|
||||
listEvents: (startTs: number, endTs: number) => invoke<Event[]>('list_events', { startTs, endTs }),
|
||||
createEvent: (newEvent: NewEvent) => invoke<Event>('create_event', { newEvent }),
|
||||
updateEvent: (event: Event) => invoke<void>('update_event', { event }),
|
||||
deleteEvent: (eventId: string) => invoke<void>('delete_event', { eventId }),
|
||||
updateEventOccurrence: (baseId: string, occurrenceStart: number, data: NewEvent) =>
|
||||
invoke<Event>('update_event_occurrence', { baseId, occurrenceStart, data }),
|
||||
deleteEventOccurrence: (baseId: string, occurrenceStart: number) =>
|
||||
invoke<void>('delete_event_occurrence', { baseId, occurrenceStart }),
|
||||
listCaldavAccounts: () => invoke<CaldavAccount[]>('list_caldav_accounts'),
|
||||
addCaldavAccount: (newAccount: NewCaldavAccount) => invoke<CaldavAccount>('add_caldav_account', { newAccount }),
|
||||
deleteCaldavAccount: (accountId: string) => invoke<void>('delete_caldav_account', { accountId }),
|
||||
syncCaldavCalendar: (accountId: string) => invoke<number>('sync_caldav_calendar', { accountId }),
|
||||
importIcsFile: (path: string) => invoke<number>('import_ics_file', { path }),
|
||||
importIcsText: (text: string) => invoke<number>('import_ics_text', { text }),
|
||||
previewInvite: (icsBase64: string) => invoke<InviteInfo | null>('preview_invite', { icsBase64 }),
|
||||
replyToInvite: (accountId: string, icsBase64: string, response: InviteResponse) =>
|
||||
invoke<void>('reply_to_invite', { accountId, icsBase64, response }),
|
||||
getVacationSettings: (accountId: string) => invoke<VacationSettings>('get_vacation_settings', { accountId }),
|
||||
saveVacationSettings: (accountId: string, settings: NewVacationSettings) =>
|
||||
invoke<void>('save_vacation_settings', { accountId, settings }),
|
||||
snoozeMessage: (accountId: string, folder: string, uid: number, untilTs: number) =>
|
||||
invoke<void>('snooze_message', { accountId, folder, uid, untilTs }),
|
||||
unsnoozeMessage: (accountId: string, folder: string, uid: number) =>
|
||||
invoke<void>('unsnooze_message', { accountId, folder, uid }),
|
||||
listSnoozedMessages: () => invoke<SnoozedMessage[]>('list_snoozed_messages'),
|
||||
listTemplates: () => invoke<Template[]>('list_templates'),
|
||||
createTemplate: (newTemplate: NewTemplate) => invoke<Template>('create_template', { newTemplate }),
|
||||
updateTemplate: (template: Template) => invoke<void>('update_template', { template }),
|
||||
deleteTemplate: (templateId: string) => invoke<void>('delete_template', { templateId }),
|
||||
searchMessagesAdvanced: (
|
||||
query: string,
|
||||
from: string | undefined,
|
||||
dateFrom: number | undefined,
|
||||
dateTo: number | undefined,
|
||||
hasAttachment: boolean,
|
||||
) => invoke<MessageSummary[]>('search_messages_advanced', { query, from, dateFrom, dateTo, hasAttachment }),
|
||||
exportEventsIcs: (path: string) => invoke<number>('export_events_ics', { path }),
|
||||
getEvent: (eventId: string) => invoke<Event>('get_event', { eventId }),
|
||||
listUpcomingReminders: (withinHours: number) => invoke<Event[]>('list_upcoming_reminders', { withinHours }),
|
||||
syncGoogleCalendar: (accountId: string) => invoke<number>('sync_google_calendar', { accountId }),
|
||||
syncGoogleContacts: (accountId: string) => invoke<number>('sync_google_contacts', { accountId }),
|
||||
reconnectGoogleAccount: (accountId: string) => invoke<void>('reconnect_google_account', { accountId }),
|
||||
setDesktopNotificationsEnabled: (enabled: boolean) =>
|
||||
invoke<void>('set_desktop_notifications_enabled', { enabled }),
|
||||
listTasks: () => invoke<Task[]>('list_tasks'),
|
||||
createTask: (newTask: NewTask) => invoke<Task>('create_task', { newTask }),
|
||||
updateTask: (task: Task) => invoke<void>('update_task', { task }),
|
||||
deleteTask: (taskId: string) => invoke<void>('delete_task', { taskId }),
|
||||
setTaskCompleted: (taskId: string, completed: boolean) =>
|
||||
invoke<void>('set_task_completed', { taskId, completed }),
|
||||
createTaskFromMessage: (accountId: string, folder: string, uid: number, title: string) =>
|
||||
invoke<Task>('create_task_from_message', { accountId, folder, uid, title }),
|
||||
listContactGroups: () => invoke<ContactGroup[]>('list_contact_groups'),
|
||||
createContactGroup: (newGroup: NewContactGroup) => invoke<ContactGroup>('create_contact_group', { newGroup }),
|
||||
updateContactGroup: (group: ContactGroup) => invoke<void>('update_contact_group', { group }),
|
||||
deleteContactGroup: (groupId: string) => invoke<void>('delete_contact_group', { groupId }),
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface CategoryColor {
|
||||
id: string
|
||||
label: string
|
||||
hex: string
|
||||
}
|
||||
|
||||
export const CATEGORY_COLORS: CategoryColor[] = [
|
||||
{ id: 'red', label: 'Rot', hex: '#ef4444' },
|
||||
{ id: 'orange', label: 'Orange', hex: '#f97316' },
|
||||
{ id: 'yellow', label: 'Gelb', hex: '#eab308' },
|
||||
{ id: 'green', label: 'Grün', hex: '#22c55e' },
|
||||
{ id: 'blue', label: 'Blau', hex: '#3b82f6' },
|
||||
{ id: 'purple', label: 'Lila', hex: '#a855f7' },
|
||||
]
|
||||
|
||||
export function categoryHex(id: string | null | undefined): string | null {
|
||||
if (!id) return null
|
||||
return CATEGORY_COLORS.find((c) => c.id === id)?.hex ?? null
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { format, isThisYear, isToday } from 'date-fns'
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
'#6366f1', '#8b5cf6', '#ec4899', '#f59e0b',
|
||||
'#10b981', '#06b6d4', '#3b82f6', '#ef4444',
|
||||
]
|
||||
|
||||
export function initials(name: string): string {
|
||||
const clean = name.trim()
|
||||
if (!clean) return '?'
|
||||
const parts = clean.split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
export function avatarColor(seed: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = seed.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]
|
||||
}
|
||||
|
||||
export function formatListDate(timestamp: number): string {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp * 1000)
|
||||
if (isToday(date)) return format(date, 'HH:mm')
|
||||
if (isThisYear(date)) return format(date, 'd. MMM')
|
||||
return format(date, 'd. MMM yyyy')
|
||||
}
|
||||
|
||||
export function formatFullDate(rawDate: string): string {
|
||||
const date = new Date(rawDate)
|
||||
if (Number.isNaN(date.getTime())) return rawDate
|
||||
return format(date, 'EEEE, d. MMMM yyyy, HH:mm')
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Protocol } from '../types'
|
||||
|
||||
export interface ProviderPreset {
|
||||
id: string
|
||||
label: string
|
||||
color: string
|
||||
protocol: Protocol
|
||||
incoming_host: string
|
||||
incoming_port: number
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
smtp_starttls: boolean
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
id: 'gmail',
|
||||
label: 'Google Mail',
|
||||
color: '#ea4335',
|
||||
protocol: 'imap',
|
||||
incoming_host: 'imap.gmail.com',
|
||||
incoming_port: 993,
|
||||
smtp_host: 'smtp.gmail.com',
|
||||
smtp_port: 465,
|
||||
smtp_starttls: false,
|
||||
hint: 'Nutze ein App-Passwort (Google-Konto -> Sicherheit -> App-Passwörter), nicht dein normales Passwort.',
|
||||
},
|
||||
{
|
||||
id: 'outlook',
|
||||
label: 'Outlook / Microsoft 365',
|
||||
color: '#0078d4',
|
||||
protocol: 'imap',
|
||||
incoming_host: 'outlook.office365.com',
|
||||
incoming_port: 993,
|
||||
smtp_host: 'smtp.office365.com',
|
||||
smtp_port: 587,
|
||||
smtp_starttls: true,
|
||||
},
|
||||
{
|
||||
id: 'yahoo',
|
||||
label: 'Yahoo Mail',
|
||||
color: '#6001d2',
|
||||
protocol: 'imap',
|
||||
incoming_host: 'imap.mail.yahoo.com',
|
||||
incoming_port: 993,
|
||||
smtp_host: 'smtp.mail.yahoo.com',
|
||||
smtp_port: 465,
|
||||
smtp_starttls: false,
|
||||
hint: 'Erfordert ein App-Passwort aus den Yahoo-Kontoeinstellungen.',
|
||||
},
|
||||
{
|
||||
id: 'icloud',
|
||||
label: 'iCloud Mail',
|
||||
color: '#3693f3',
|
||||
protocol: 'imap',
|
||||
incoming_host: 'imap.mail.me.com',
|
||||
incoming_port: 993,
|
||||
smtp_host: 'smtp.mail.me.com',
|
||||
smtp_port: 587,
|
||||
smtp_starttls: true,
|
||||
hint: 'Erfordert ein app-spezifisches Passwort von appleid.apple.com.',
|
||||
},
|
||||
{
|
||||
id: 'custom-imap',
|
||||
label: 'Custom IMAP',
|
||||
color: '#64748b',
|
||||
protocol: 'imap',
|
||||
incoming_host: '',
|
||||
incoming_port: 993,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_starttls: true,
|
||||
},
|
||||
{
|
||||
id: 'custom-pop3',
|
||||
label: 'Custom POP3',
|
||||
color: '#64748b',
|
||||
protocol: 'pop3',
|
||||
incoming_host: '',
|
||||
incoming_port: 995,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_starttls: true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
export function htmlIsEmpty(html: string): boolean {
|
||||
return !html || html.replace(/<[^>]*>/g, '').trim().length === 0
|
||||
}
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]!)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface AppSettings {
|
||||
desktop: boolean
|
||||
popup: boolean
|
||||
composePopup: boolean
|
||||
}
|
||||
|
||||
const KEY = 'mailclient.notifySettings'
|
||||
|
||||
const DEFAULTS: AppSettings = { desktop: true, popup: true, composePopup: false }
|
||||
|
||||
export function loadAppSettings(): AppSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return DEFAULTS
|
||||
const parsed = JSON.parse(raw) as Partial<AppSettings>
|
||||
return {
|
||||
desktop: parsed.desktop ?? DEFAULTS.desktop,
|
||||
popup: parsed.popup ?? DEFAULTS.popup,
|
||||
composePopup: parsed.composePopup ?? DEFAULTS.composePopup,
|
||||
}
|
||||
} catch {
|
||||
return DEFAULTS
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAppSettings(v: AppSettings) {
|
||||
localStorage.setItem(KEY, JSON.stringify(v))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export interface Signature {
|
||||
id: string
|
||||
name: string
|
||||
body: string
|
||||
}
|
||||
|
||||
interface AccountSignatures {
|
||||
signatures: Signature[]
|
||||
defaultId: string | null
|
||||
}
|
||||
|
||||
const KEY = 'mailclient.signatures.v2'
|
||||
const LEGACY_KEY = 'mailclient.signatures'
|
||||
|
||||
function migrateLegacy(): Record<string, AccountSignatures> {
|
||||
const out: Record<string, AccountSignatures> = {}
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_KEY)
|
||||
if (!raw) return out
|
||||
const legacy = JSON.parse(raw) as Record<string, string>
|
||||
for (const [accountId, body] of Object.entries(legacy)) {
|
||||
if (!body.trim()) continue
|
||||
const id = crypto.randomUUID()
|
||||
out[accountId] = { signatures: [{ id, name: 'Standard', body }], defaultId: id }
|
||||
}
|
||||
localStorage.setItem(KEY, JSON.stringify(out))
|
||||
} catch {
|
||||
// ignore malformed legacy data
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function loadAll(): Record<string, AccountSignatures> {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return migrateLegacy()
|
||||
return JSON.parse(raw) as Record<string, AccountSignatures>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function saveAll(all: Record<string, AccountSignatures>) {
|
||||
localStorage.setItem(KEY, JSON.stringify(all))
|
||||
}
|
||||
|
||||
export function listSignatures(accountId: string): Signature[] {
|
||||
return loadAll()[accountId]?.signatures ?? []
|
||||
}
|
||||
|
||||
export function getDefaultSignature(accountId: string): Signature | null {
|
||||
const acc = loadAll()[accountId]
|
||||
if (!acc || acc.signatures.length === 0) return null
|
||||
return acc.signatures.find((s) => s.id === acc.defaultId) ?? acc.signatures[0]
|
||||
}
|
||||
|
||||
export function getDefaultSignatureId(accountId: string): string | null {
|
||||
return loadAll()[accountId]?.defaultId ?? null
|
||||
}
|
||||
|
||||
export function saveSignatureList(accountId: string, signatures: Signature[], defaultId: string | null) {
|
||||
const all = loadAll()
|
||||
if (signatures.length === 0) {
|
||||
delete all[accountId]
|
||||
} else {
|
||||
const resolvedDefault = signatures.some((s) => s.id === defaultId) ? defaultId : signatures[0].id
|
||||
all[accountId] = { signatures, defaultId: resolvedDefault }
|
||||
}
|
||||
saveAll(all)
|
||||
}
|
||||
|
||||
/// Back-compat helper: the single "signature text" a fresh compose should
|
||||
/// start with — the account's default signature body, or empty.
|
||||
export function loadSignature(accountId: string): string {
|
||||
return getDefaultSignature(accountId)?.body ?? ''
|
||||
}
|
||||
|
||||
/// Sets the body of the account's default signature (creating a "Standard"
|
||||
/// signature if none exists yet), leaving any other named signatures intact.
|
||||
/// Used by the simple single-field signature editor in account settings.
|
||||
export function saveSignature(accountId: string, body: string) {
|
||||
const existing = listSignatures(accountId)
|
||||
const defaultId = getDefaultSignatureId(accountId)
|
||||
const current = existing.find((s) => s.id === defaultId) ?? existing[0]
|
||||
if (current) {
|
||||
const next = existing.map((s) => (s.id === current.id ? { ...s, body } : s))
|
||||
saveSignatureList(accountId, next, current.id)
|
||||
} else if (body.trim()) {
|
||||
const sig: Signature = { id: crypto.randomUUID(), name: 'Standard', body }
|
||||
saveSignatureList(accountId, [sig], sig.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
export type Density = 'comfortable' | 'compact'
|
||||
|
||||
const THEME_KEY = 'mailclient.theme'
|
||||
const DENSITY_KEY = 'mailclient.density'
|
||||
const FAVORITES_KEY = 'mailclient.favorites'
|
||||
const CONVERSATIONS_KEY = 'mailclient.conversations'
|
||||
|
||||
export function loadTheme(): Theme {
|
||||
const v = localStorage.getItem(THEME_KEY)
|
||||
return v === 'light' || v === 'dark' || v === 'system' ? v : 'system'
|
||||
}
|
||||
|
||||
export function saveTheme(t: Theme) {
|
||||
localStorage.setItem(THEME_KEY, t)
|
||||
}
|
||||
|
||||
export function applyTheme(t: Theme) {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const dark = t === 'dark' || (t === 'system' && prefersDark)
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
}
|
||||
|
||||
export function loadDensity(): Density {
|
||||
const v = localStorage.getItem(DENSITY_KEY)
|
||||
return v === 'compact' ? 'compact' : 'comfortable'
|
||||
}
|
||||
|
||||
export function saveDensity(d: Density) {
|
||||
localStorage.setItem(DENSITY_KEY, d)
|
||||
}
|
||||
|
||||
export function loadFavoritesRaw<T>(): T[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAVORITES_KEY)
|
||||
return raw ? (JSON.parse(raw) as T[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function saveFavoritesRaw<T>(favorites: T[]) {
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites))
|
||||
}
|
||||
|
||||
export function loadConversationsEnabled(): boolean {
|
||||
return localStorage.getItem(CONVERSATIONS_KEY) === '1'
|
||||
}
|
||||
|
||||
export function saveConversationsEnabled(enabled: boolean) {
|
||||
localStorage.setItem(CONVERSATIONS_KEY, enabled ? '1' : '0')
|
||||
}
|
||||
Reference in New Issue
Block a user