feat: add profile pages (favorites, messages, my-listings, settings) and update docs for zero-knowledge chat schema

This commit is contained in:
2026-02-04 12:07:16 +01:00
parent 3643bed7ca
commit 915fe685fb
11 changed files with 1366 additions and 31 deletions

View File

@@ -7,6 +7,10 @@ import './auth-modal.js'
import './pages/page-home.js'
import './pages/page-listing.js'
import './pages/page-create.js'
import './pages/page-favorites.js'
import './pages/page-my-listings.js'
import './pages/page-messages.js'
import './pages/page-settings.js'
import './pages/page-not-found.js'
class AppShell extends HTMLElement {
@@ -47,6 +51,10 @@ class AppShell extends HTMLElement {
.register('/search', 'page-home') // Redirect search to home
.register('/listing/:id', 'page-listing')
.register('/create', 'page-create')
.register('/favorites', 'page-favorites')
.register('/my-listings', 'page-my-listings')
.register('/messages', 'page-messages')
.register('/settings', 'page-settings')
router.handleRouteChange()
}

View File

@@ -0,0 +1,171 @@
import { t, i18n } from '../../i18n.js'
import { directus } from '../../services/directus.js'
import '../listing-card.js'
import '../skeleton-card.js'
class PageFavorites extends HTMLElement {
constructor() {
super()
this.listings = []
this.loading = true
this.error = null
}
connectedCallback() {
this.render()
this.loadFavorites()
this.unsubscribe = i18n.subscribe(() => this.render())
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
}
getFavoriteIds() {
return JSON.parse(localStorage.getItem('favorites') || '[]')
}
async loadFavorites() {
const ids = this.getFavoriteIds()
if (ids.length === 0) {
this.loading = false
this.listings = []
this.updateContent()
return
}
try {
const response = await directus.getListings({
filter: {
id: { _in: ids },
status: { _eq: 'published' }
},
limit: 50
})
this.listings = response.items || []
this.loading = false
this.updateContent()
} catch (err) {
console.error('Failed to load favorites:', err)
this.error = err.message
this.loading = false
this.updateContent()
}
}
render() {
this.innerHTML = /* html */`
<div class="favorites-page">
<header class="page-header">
<h1>${t('favorites.title')}</h1>
<p class="page-subtitle">${t('favorites.subtitle')}</p>
</header>
<div id="favorites-content" class="listings-grid">
${this.renderContent()}
</div>
</div>
`
}
updateContent() {
const container = this.querySelector('#favorites-content')
if (container) {
container.innerHTML = this.renderContent()
}
}
renderContent() {
if (this.loading) {
return Array(4).fill(0).map(() => '<skeleton-card></skeleton-card>').join('')
}
if (this.error) {
return /* html */`
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<p>${t('common.error')}</p>
</div>
`
}
if (this.listings.length === 0) {
return /* html */`
<div class="empty-state">
<div class="empty-icon">❤️</div>
<h3>${t('favorites.empty')}</h3>
<p>${t('favorites.emptyHint')}</p>
<a href="#/" class="btn btn-primary">${t('favorites.browse')}</a>
</div>
`
}
return this.listings.map(listing => {
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : ''
const locationName = listing.location?.name || ''
return /* html */`
<listing-card
listing-id="${listing.id}"
title="${this.escapeHtml(listing.title || '')}"
price="${listing.price || ''}"
currency="${listing.currency || 'EUR'}"
location="${this.escapeHtml(locationName)}"
image="${imageUrl}"
></listing-card>
`
}).join('')
}
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}
customElements.define('page-favorites', PageFavorites)
const style = document.createElement('style')
style.textContent = /* css */`
page-favorites .favorites-page {
padding: var(--space-lg) 0;
}
page-favorites .page-header {
margin-bottom: var(--space-xl);
}
page-favorites .page-header h1 {
margin: 0 0 var(--space-xs);
}
page-favorites .page-subtitle {
color: var(--color-text-muted);
margin: 0;
}
page-favorites .empty-state {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-3xl);
}
page-favorites .empty-icon {
font-size: 4rem;
margin-bottom: var(--space-md);
filter: grayscale(1);
}
page-favorites .empty-state h3 {
margin: 0 0 var(--space-sm);
}
page-favorites .empty-state p {
color: var(--color-text-muted);
margin: 0 0 var(--space-lg);
}
`
document.head.appendChild(style)

View File

@@ -0,0 +1,333 @@
import { t, i18n } from '../../i18n.js'
import { auth } from '../../services/auth.js'
import { directus } from '../../services/directus.js'
class PageMessages extends HTMLElement {
constructor() {
super()
this.conversations = []
this.loading = true
this.error = null
this.isLoggedIn = false
}
connectedCallback() {
this.isLoggedIn = auth.isLoggedIn()
this.render()
if (this.isLoggedIn) {
this.loadConversations()
} else {
this.loading = false
}
this.unsubscribe = i18n.subscribe(() => this.render())
this.authUnsubscribe = auth.subscribe(() => {
const wasLoggedIn = this.isLoggedIn
this.isLoggedIn = auth.isLoggedIn()
if (!wasLoggedIn && this.isLoggedIn) {
this.loading = true
this.render()
this.loadConversations()
} else if (wasLoggedIn && !this.isLoggedIn) {
this.conversations = []
this.loading = false
this.render()
}
})
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
if (this.authUnsubscribe) this.authUnsubscribe()
}
async loadConversations() {
try {
const user = auth.getUser()
if (!user) {
this.loading = false
this.updateContent()
return
}
// Load conversations for current user (matched by participant hash)
const userHash = await auth.hashString(user.id)
const response = await directus.get('/items/conversations', {
fields: [
'id',
'date_created',
'date_updated',
'listing_id.id',
'listing_id.title',
'listing_id.images.directus_files_id.id',
'status'
],
filter: {
_or: [
{ participant_hash_1: { _eq: userHash } },
{ participant_hash_2: { _eq: userHash } }
]
},
sort: ['-date_updated'],
limit: 50
})
this.conversations = response.data || []
this.loading = false
this.updateContent()
} catch (err) {
console.error('Failed to load conversations:', err)
this.error = err.message
this.loading = false
this.updateContent()
}
}
showAuthModal() {
document.querySelector('auth-modal')?.show()
}
render() {
this.innerHTML = /* html */`
<div class="messages-page">
<header class="page-header">
<h1>${t('messages.title')}</h1>
<p class="page-subtitle">${t('messages.subtitle')}</p>
</header>
<div id="messages-content" class="conversations-list">
${this.renderContent()}
</div>
</div>
`
this.querySelector('#login-btn')?.addEventListener('click', () => this.showAuthModal())
}
updateContent() {
const container = this.querySelector('#messages-content')
if (container) {
container.innerHTML = this.renderContent()
this.querySelector('#login-btn')?.addEventListener('click', () => this.showAuthModal())
}
}
renderContent() {
if (!this.isLoggedIn) {
return /* html */`
<div class="empty-state">
<div class="empty-icon">🔐</div>
<h3>${t('messages.loginRequired')}</h3>
<p>${t('messages.loginHint')}</p>
<button id="login-btn" class="btn btn-primary">${t('messages.login')}</button>
</div>
`
}
if (this.loading) {
return /* html */`
<div class="loading-state">
<div class="spinner"></div>
<p>${t('common.loading')}</p>
</div>
`
}
if (this.error) {
return /* html */`
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<p>${t('common.error')}</p>
</div>
`
}
if (this.conversations.length === 0) {
return /* html */`
<div class="empty-state">
<div class="empty-icon">💬</div>
<h3>${t('messages.empty')}</h3>
<p>${t('messages.emptyHint')}</p>
<a href="#/" class="btn btn-primary">${t('messages.browse')}</a>
</div>
`
}
return this.conversations.map(conv => {
const listing = conv.listing_id
const imageId = listing?.images?.[0]?.directus_files_id?.id
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 80) : ''
const title = listing?.title || t('messages.unknownListing')
const dateStr = this.formatDate(conv.date_updated || conv.date_created)
return /* html */`
<a href="#/listing/${listing?.id}" class="conversation-item">
<div class="conversation-image">
${imageUrl
? `<img src="${imageUrl}" alt="" loading="lazy">`
: `<div class="image-placeholder">📦</div>`}
</div>
<div class="conversation-info">
<h3 class="conversation-title">${this.escapeHtml(title)}</h3>
<p class="conversation-date">${dateStr}</p>
</div>
<div class="conversation-arrow">→</div>
</a>
`
}).join('')
}
formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const now = new Date()
const diffMs = now - date
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) return t('messages.today')
if (diffDays === 1) return t('messages.yesterday')
if (diffDays < 7) return t('messages.daysAgo', { days: diffDays })
return date.toLocaleDateString()
}
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}
customElements.define('page-messages', PageMessages)
const style = document.createElement('style')
style.textContent = /* css */`
page-messages .messages-page {
padding: var(--space-lg) 0;
}
page-messages .page-header {
margin-bottom: var(--space-xl);
}
page-messages .page-header h1 {
margin: 0 0 var(--space-xs);
}
page-messages .page-subtitle {
color: var(--color-text-muted);
margin: 0;
}
page-messages .conversations-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
page-messages .conversation-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
transition: all var(--transition-fast);
}
page-messages .conversation-item:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-sm);
}
page-messages .conversation-image {
width: 60px;
height: 60px;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-bg-tertiary);
flex-shrink: 0;
}
page-messages .conversation-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
page-messages .image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
filter: grayscale(1);
}
page-messages .conversation-info {
flex: 1;
min-width: 0;
}
page-messages .conversation-title {
margin: 0 0 var(--space-xs);
font-size: var(--font-size-base);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
page-messages .conversation-date {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
page-messages .conversation-arrow {
color: var(--color-text-muted);
font-size: var(--font-size-lg);
}
page-messages .loading-state,
page-messages .empty-state {
text-align: center;
padding: var(--space-3xl);
}
page-messages .spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--space-md);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
page-messages .empty-icon {
font-size: 4rem;
margin-bottom: var(--space-md);
filter: grayscale(1);
}
page-messages .empty-state h3 {
margin: 0 0 var(--space-sm);
}
page-messages .empty-state p {
color: var(--color-text-muted);
margin: 0 0 var(--space-lg);
}
`
document.head.appendChild(style)

View File

@@ -0,0 +1,239 @@
import { t, i18n } from '../../i18n.js'
import { directus } from '../../services/directus.js'
import { auth } from '../../services/auth.js'
import '../listing-card.js'
import '../skeleton-card.js'
class PageMyListings extends HTMLElement {
constructor() {
super()
this.listings = []
this.loading = true
this.error = null
this.isLoggedIn = false
}
connectedCallback() {
this.isLoggedIn = auth.isLoggedIn()
this.render()
if (this.isLoggedIn) {
this.loadMyListings()
} else {
this.loading = false
}
this.unsubscribe = i18n.subscribe(() => this.render())
this.authUnsubscribe = auth.subscribe(() => {
const wasLoggedIn = this.isLoggedIn
this.isLoggedIn = auth.isLoggedIn()
if (!wasLoggedIn && this.isLoggedIn) {
this.loading = true
this.render()
this.loadMyListings()
} else if (wasLoggedIn && !this.isLoggedIn) {
this.listings = []
this.loading = false
this.render()
}
})
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
if (this.authUnsubscribe) this.authUnsubscribe()
}
async loadMyListings() {
try {
const user = auth.getUser()
if (!user) {
this.loading = false
this.updateContent()
return
}
const response = await directus.getListings({
filter: {
user_created: { _eq: user.id }
},
sort: ['-date_created'],
limit: 50
})
this.listings = response.items || []
this.loading = false
this.updateContent()
} catch (err) {
console.error('Failed to load my listings:', err)
this.error = err.message
this.loading = false
this.updateContent()
}
}
showAuthModal() {
document.querySelector('auth-modal')?.show()
}
render() {
this.innerHTML = /* html */`
<div class="my-listings-page">
<header class="page-header">
<h1>${t('myListings.title')}</h1>
<p class="page-subtitle">${t('myListings.subtitle')}</p>
</header>
<div id="my-listings-content" class="listings-grid">
${this.renderContent()}
</div>
</div>
`
this.querySelector('#login-btn')?.addEventListener('click', () => this.showAuthModal())
}
updateContent() {
const container = this.querySelector('#my-listings-content')
if (container) {
container.innerHTML = this.renderContent()
this.querySelector('#login-btn')?.addEventListener('click', () => this.showAuthModal())
}
}
renderContent() {
if (!this.isLoggedIn) {
return /* html */`
<div class="empty-state">
<div class="empty-icon">🔐</div>
<h3>${t('myListings.loginRequired')}</h3>
<p>${t('myListings.loginHint')}</p>
<button id="login-btn" class="btn btn-primary">${t('myListings.login')}</button>
</div>
`
}
if (this.loading) {
return Array(4).fill(0).map(() => '<skeleton-card></skeleton-card>').join('')
}
if (this.error) {
return /* html */`
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<p>${t('common.error')}</p>
</div>
`
}
if (this.listings.length === 0) {
return /* html */`
<div class="empty-state">
<div class="empty-icon">📦</div>
<h3>${t('myListings.empty')}</h3>
<p>${t('myListings.emptyHint')}</p>
<a href="#/create" class="btn btn-primary">${t('myListings.create')}</a>
</div>
`
}
return this.listings.map(listing => {
const imageId = listing.images?.[0]?.directus_files_id?.id || listing.images?.[0]?.directus_files_id
const imageUrl = imageId ? directus.getThumbnailUrl(imageId, 300) : ''
const locationName = listing.location?.name || ''
const statusBadge = listing.status !== 'published'
? `<span class="status-badge status-${listing.status}">${t(`myListings.status.${listing.status}`)}</span>`
: ''
return /* html */`
<div class="listing-wrapper">
${statusBadge}
<listing-card
listing-id="${listing.id}"
title="${this.escapeHtml(listing.title || '')}"
price="${listing.price || ''}"
currency="${listing.currency || 'EUR'}"
location="${this.escapeHtml(locationName)}"
image="${imageUrl}"
></listing-card>
</div>
`
}).join('')
}
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}
customElements.define('page-my-listings', PageMyListings)
const style = document.createElement('style')
style.textContent = /* css */`
page-my-listings .my-listings-page {
padding: var(--space-lg) 0;
}
page-my-listings .page-header {
margin-bottom: var(--space-xl);
}
page-my-listings .page-header h1 {
margin: 0 0 var(--space-xs);
}
page-my-listings .page-subtitle {
color: var(--color-text-muted);
margin: 0;
}
page-my-listings .listing-wrapper {
position: relative;
}
page-my-listings .status-badge {
position: absolute;
top: var(--space-sm);
left: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-sm);
z-index: 2;
}
page-my-listings .status-draft {
background: var(--color-bg-tertiary);
color: var(--color-text-muted);
}
page-my-listings .status-archived {
background: var(--color-bg-tertiary);
color: var(--color-text-muted);
text-decoration: line-through;
}
page-my-listings .empty-state {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-3xl);
}
page-my-listings .empty-icon {
font-size: 4rem;
margin-bottom: var(--space-md);
filter: grayscale(1);
}
page-my-listings .empty-state h3 {
margin: 0 0 var(--space-sm);
}
page-my-listings .empty-state p {
color: var(--color-text-muted);
margin: 0 0 var(--space-lg);
}
`
document.head.appendChild(style)

View File

@@ -0,0 +1,382 @@
import { t, i18n } from '../../i18n.js'
import { auth } from '../../services/auth.js'
class PageSettings extends HTMLElement {
constructor() {
super()
this.isLoggedIn = false
}
connectedCallback() {
this.isLoggedIn = auth.isLoggedIn()
this.render()
this.setupEventListeners()
this.unsubscribe = i18n.subscribe(() => {
this.render()
this.setupEventListeners()
})
this.authUnsubscribe = auth.subscribe(() => {
this.isLoggedIn = auth.isLoggedIn()
this.render()
this.setupEventListeners()
})
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
if (this.authUnsubscribe) this.authUnsubscribe()
}
setupEventListeners() {
// Theme toggle
this.querySelectorAll('input[name="theme"]').forEach(input => {
input.addEventListener('change', (e) => {
this.setTheme(e.target.value)
})
})
// Language select
this.querySelector('#lang-select')?.addEventListener('change', (e) => {
i18n.setLocale(e.target.value)
})
// Clear favorites
this.querySelector('#clear-favorites')?.addEventListener('click', () => {
if (confirm(t('settings.confirmClearFavorites'))) {
localStorage.removeItem('favorites')
this.showToast(t('settings.favoritesCleared'))
}
})
// Clear search history
this.querySelector('#clear-search')?.addEventListener('click', () => {
if (confirm(t('settings.confirmClearSearch'))) {
localStorage.removeItem('searchFilters')
this.showToast(t('settings.searchCleared'))
}
})
// Logout
this.querySelector('#logout-btn')?.addEventListener('click', () => {
auth.logout()
this.showToast(t('settings.loggedOut'))
})
// Login
this.querySelector('#login-btn')?.addEventListener('click', () => {
document.querySelector('auth-modal')?.show()
})
}
setTheme(theme) {
if (theme === 'system') {
localStorage.removeItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.dataset.theme = prefersDark ? 'dark' : 'light'
} else {
localStorage.setItem('theme', theme)
document.documentElement.dataset.theme = theme
}
}
getCurrentTheme() {
return localStorage.getItem('theme') || 'system'
}
showToast(message) {
const existing = document.querySelector('.settings-toast')
existing?.remove()
const toast = document.createElement('div')
toast.className = 'settings-toast'
toast.textContent = message
document.body.appendChild(toast)
requestAnimationFrame(() => toast.classList.add('visible'))
setTimeout(() => {
toast.classList.remove('visible')
setTimeout(() => toast.remove(), 300)
}, 2000)
}
render() {
const currentTheme = this.getCurrentTheme()
const currentLang = i18n.getLocale()
const user = auth.getUser()
this.innerHTML = /* html */`
<div class="settings-page">
<header class="page-header">
<h1>${t('settings.title')}</h1>
</header>
<div class="settings-sections">
<!-- Appearance -->
<section class="settings-section">
<h2>${t('settings.appearance')}</h2>
<div class="setting-item">
<label>${t('settings.theme')}</label>
<div class="theme-options">
<label class="theme-option">
<input type="radio" name="theme" value="light" ${currentTheme === 'light' ? 'checked' : ''}>
<span>☀️ ${t('settings.themeLight')}</span>
</label>
<label class="theme-option">
<input type="radio" name="theme" value="dark" ${currentTheme === 'dark' ? 'checked' : ''}>
<span>🌙 ${t('settings.themeDark')}</span>
</label>
<label class="theme-option">
<input type="radio" name="theme" value="system" ${currentTheme === 'system' ? 'checked' : ''}>
<span>💻 ${t('settings.themeSystem')}</span>
</label>
</div>
</div>
<div class="setting-item">
<label for="lang-select">${t('settings.language')}</label>
<select id="lang-select">
<option value="de" ${currentLang === 'de' ? 'selected' : ''}>Deutsch</option>
<option value="en" ${currentLang === 'en' ? 'selected' : ''}>English</option>
<option value="fr" ${currentLang === 'fr' ? 'selected' : ''}>Français</option>
</select>
</div>
</section>
<!-- Account -->
<section class="settings-section">
<h2>${t('settings.account')}</h2>
${this.isLoggedIn ? /* html */`
<div class="setting-item">
<label>${t('settings.userId')}</label>
<code class="user-id">${user?.id?.slice(0, 8)}...</code>
</div>
<div class="setting-item">
<button id="logout-btn" class="btn btn-outline btn-danger">
${t('settings.logout')}
</button>
</div>
` : /* html */`
<div class="setting-item">
<p class="setting-hint">${t('settings.notLoggedIn')}</p>
<button id="login-btn" class="btn btn-primary">
${t('settings.login')}
</button>
</div>
`}
</section>
<!-- Data -->
<section class="settings-section">
<h2>${t('settings.data')}</h2>
<div class="setting-item">
<div>
<label>${t('settings.favorites')}</label>
<p class="setting-hint">${t('settings.favoritesHint')}</p>
</div>
<button id="clear-favorites" class="btn btn-outline btn-sm">
${t('settings.clear')}
</button>
</div>
<div class="setting-item">
<div>
<label>${t('settings.searchHistory')}</label>
<p class="setting-hint">${t('settings.searchHistoryHint')}</p>
</div>
<button id="clear-search" class="btn btn-outline btn-sm">
${t('settings.clear')}
</button>
</div>
</section>
<!-- About -->
<section class="settings-section">
<h2>${t('settings.about')}</h2>
<div class="about-links">
<a href="#/about">${t('footer.about')}</a>
<a href="#/privacy">${t('footer.privacy')}</a>
<a href="#/terms">${t('footer.terms')}</a>
<a href="#/contact">${t('footer.contact')}</a>
</div>
<p class="version">dgray.io v1.0.0</p>
</section>
</div>
</div>
`
}
}
customElements.define('page-settings', PageSettings)
const style = document.createElement('style')
style.textContent = /* css */`
page-settings .settings-page {
padding: var(--space-lg) 0;
max-width: 600px;
}
page-settings .page-header {
margin-bottom: var(--space-xl);
}
page-settings .page-header h1 {
margin: 0;
}
page-settings .settings-sections {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
page-settings .settings-section {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
page-settings .settings-section h2 {
font-size: var(--font-size-lg);
margin: 0 0 var(--space-lg);
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--color-border);
}
page-settings .setting-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) 0;
}
page-settings .setting-item + .setting-item {
border-top: 1px solid var(--color-border);
margin-top: var(--space-sm);
padding-top: var(--space-md);
}
page-settings .setting-item > label {
font-weight: var(--font-weight-medium);
}
page-settings .setting-hint {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: var(--space-xs) 0 0;
}
page-settings .theme-options {
display: flex;
gap: var(--space-sm);
}
page-settings .theme-option {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-size-sm);
transition: all var(--transition-fast);
}
page-settings .theme-option:has(input:checked) {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
page-settings .theme-option input {
display: none;
}
page-settings select {
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
}
page-settings .user-id {
padding: var(--space-xs) var(--space-sm);
background: var(--color-bg-tertiary);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
page-settings .btn-danger {
color: var(--color-error, #dc2626);
border-color: var(--color-error, #dc2626);
}
page-settings .btn-danger:hover {
background: var(--color-error, #dc2626);
color: white;
}
page-settings .about-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
margin-bottom: var(--space-md);
}
page-settings .about-links a {
color: var(--color-text-secondary);
text-decoration: none;
}
page-settings .about-links a:hover {
color: var(--color-primary);
}
page-settings .version {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: 0;
}
@media (max-width: 768px) {
page-settings .setting-item {
flex-direction: column;
align-items: flex-start;
}
page-settings .theme-options {
flex-wrap: wrap;
}
}
/* Toast */
.settings-toast {
position: fixed;
bottom: var(--space-lg);
left: 50%;
transform: translateX(-50%) translateY(100px);
padding: var(--space-sm) var(--space-lg);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
opacity: 0;
transition: all 0.3s ease;
z-index: 1000;
}
.settings-toast.visible {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
`
document.head.appendChild(style)