From 4b18429945256d6584af666ef0a7375b60831ae7 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 29 Oct 2025 00:40:13 +0100 Subject: [PATCH] work --- backend/src/openapi.rs | 10 +- backend/src/routes/auth.rs | 12 +- frontend/src/DesktopWorkspace.css | 10 +- frontend/src/index.jsx | 115 +++++++++- frontend/src/settings/SettingsModal.jsx | 284 ++++++++++++++++++++++++ frontend/src/styles.css | 238 ++++++++++++++++++-- 6 files changed, 622 insertions(+), 47 deletions(-) create mode 100644 frontend/src/settings/SettingsModal.jsx diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index 2d9d310..595001d 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -54,7 +54,7 @@ use uuid::Uuid; schemas( schemas::LoginRequest, schemas::AccessTokenResponse, - schemas::TenantSummary, + schemas::TenantSnippet, schemas::TenantSelectionResponse, schemas::TenantSelectionRequest, schemas::TenantListResponse, @@ -605,12 +605,6 @@ pub mod schemas { pub tenant: TenantSnippet, } - #[derive(Serialize, Deserialize, ToSchema)] - pub struct TenantSummary { - pub tenant_id: Uuid, - pub slug: String, - } - #[derive(Serialize, Deserialize, ToSchema)] pub struct TenantSnippet { pub id: Uuid, @@ -620,7 +614,7 @@ pub mod schemas { #[derive(Serialize, Deserialize, ToSchema)] pub struct TenantSelectionResponse { pub access_token: String, - pub tenants: Vec, + pub tenants: Vec, } #[derive(Serialize, Deserialize, ToSchema)] diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 679a6b0..5334fd9 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -46,12 +46,6 @@ pub struct LoginResponse { pub tenant: TenantSnippet, } -#[derive(Serialize)] -pub struct TenantSummary { - pub tenant_id: Uuid, - pub slug: String, -} - #[derive(Serialize)] pub struct TenantSnippet { pub id: Uuid, @@ -61,7 +55,7 @@ pub struct TenantSnippet { #[derive(Serialize)] pub struct TenantSelectionResponse { pub access_token: String, - pub tenants: Vec, + pub tenants: Vec, } #[derive(Serialize)] @@ -132,8 +126,8 @@ pub async fn login( let tenants = memberships .into_iter() - .map(|(_, tenant)| TenantSummary { - tenant_id: tenant.id, + .map(|(_, tenant)| TenantSnippet { + id: tenant.id, slug: tenant.slug, }) .collect(); diff --git a/frontend/src/DesktopWorkspace.css b/frontend/src/DesktopWorkspace.css index 189be1c..698c0bd 100644 --- a/frontend/src/DesktopWorkspace.css +++ b/frontend/src/DesktopWorkspace.css @@ -100,7 +100,7 @@ pointer-events: auto; cursor: grab; transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease; - box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.18); + box-shadow: 2px 2px 4px color-mix(in oklch, black 18%, transparent); } .tag-chip--draggable:active { @@ -146,14 +146,14 @@ padding: 0.25em; border-radius: 50%; border: none; - background: rgba(0, 0, 0, 0.55); - color: #fff; + background: oklch(0.22 0.06 260deg); + color: var(--on-accent); cursor: pointer; transition: background 0.15s ease, opacity 0.15s ease; } .skeuo-card__nav-button:hover:not([disabled]) { - background: rgba(0, 0, 0, 0.75); + background: oklch(0.18 0.06 260deg); } .skeuo-card__nav-button:disabled { @@ -192,7 +192,7 @@ body.skeuo-cursor-remove * { justify-content: center; width: 100%; height: 100%; - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18); + box-shadow: 0 12px 32px color-mix(in oklch, black 18%, transparent); overflow: hidden; } diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 59210e1..d7da00d 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -539,6 +539,11 @@ const AppLayout = () => { const [searchResults, setSearchResults] = useState(null); const [tags, setTags] = useState([]); const [correspondents, setCorrespondents] = useState([]); + const [webdavTokens, setWebdavTokens] = useState([]); + const [webdavTokensLoading, setWebdavTokensLoading] = useState(false); + const [creatingWebdavToken, setCreatingWebdavToken] = useState(false); + const [deletingWebdavTokenId, setDeletingWebdavTokenId] = useState(null); + const [webdavTokenSecret, setWebdavTokenSecret] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [activeTagFilters, setActiveTagFilters] = useState([]); const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); @@ -743,6 +748,11 @@ const AppLayout = () => { setSearchResults(null); setTags([]); setCorrespondents([]); + setWebdavTokens([]); + setWebdavTokensLoading(false); + setCreatingWebdavToken(false); + setDeletingWebdavTokenId(null); + setWebdavTokenSecret(null); setSearchQuery(''); setActiveTagFilters([]); setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); @@ -1734,6 +1744,97 @@ const AppLayout = () => { } }, [api, notifyApiError]); + const refreshWebdavTokens = useCallback(async () => { + if (!token) { + return; + } + setWebdavTokensLoading(true); + try { + const { data } = await api.get('/profile/webdav-tokens'); + setWebdavTokens(Array.isArray(data) ? data : []); + } catch (error) { + notifyApiError(error, 'Failed to load WebDAV tokens.'); + } finally { + setWebdavTokensLoading(false); + } + }, [api, notifyApiError, token]); + + const createWebdavToken = useCallback( + async ({ label, expires_at } = {}) => { + if (creatingWebdavToken) { + return false; + } + setCreatingWebdavToken(true); + try { + const payload = {}; + if (label) { + payload.label = label; + } + if (expires_at) { + payload.expires_at = expires_at; + } + const { data } = await api.post('/profile/webdav-tokens', payload); + if (data?.token_info) { + setWebdavTokens((previous) => { + const filtered = previous.filter((entry) => entry.id !== data.token_info.id); + return [data.token_info, ...filtered]; + }); + } else { + await refreshWebdavTokens(); + } + if (data?.token) { + setWebdavTokenSecret(data.token); + } + setStatusMessage('WebDAV token created.', 'success'); + return data; + } catch (error) { + notifyApiError(error, 'Failed to create WebDAV token.'); + return false; + } finally { + setCreatingWebdavToken(false); + } + }, + [api, creatingWebdavToken, notifyApiError, refreshWebdavTokens, setStatusMessage], + ); + + const deleteWebdavToken = useCallback( + async (tokenId) => { + if (!tokenId) { + return false; + } + setDeletingWebdavTokenId(tokenId); + try { + await api.delete(`/profile/webdav-tokens/${tokenId}`); + setWebdavTokens((previous) => previous.filter((entry) => entry.id !== tokenId)); + setStatusMessage('WebDAV token revoked.', 'success'); + return true; + } catch (error) { + notifyApiError(error, 'Failed to revoke WebDAV token.'); + return false; + } finally { + setDeletingWebdavTokenId(null); + } + }, + [api, notifyApiError, setStatusMessage], + ); + + const dismissCreatedWebdavToken = useCallback(() => { + setWebdavTokenSecret(null); + }, []); + + useEffect(() => { + if (!isSettingsModalOpen) { + return; + } + refreshWebdavTokens(); + }, [isSettingsModalOpen, refreshWebdavTokens]); + + useEffect(() => { + if (!isSettingsModalOpen) { + setWebdavTokenSecret(null); + } + }, [isSettingsModalOpen]); + const handleTagUpdate = useCallback( async (tagId, changes) => { if (!tagId) { @@ -5404,7 +5505,19 @@ const AppLayout = () => { )} - + ); diff --git a/frontend/src/settings/SettingsModal.jsx b/frontend/src/settings/SettingsModal.jsx new file mode 100644 index 0000000..0a55277 --- /dev/null +++ b/frontend/src/settings/SettingsModal.jsx @@ -0,0 +1,284 @@ +import React, { useMemo, useState, useCallback, useEffect } from 'react'; + +const SECTIONS = [ + { + id: 'webdav', + label: 'WebDAV', + }, +]; + +const SettingsModal = ({ + open, + onClose, + tokens = [], + loading = false, + creating = false, + deletingId = null, + onRefresh, + onCreate, + onDelete, + createdToken = null, + onDismissCreatedToken, +}) => { + const defaultSection = SECTIONS[0]?.id || 'webdav'; + const [activeSection, setActiveSection] = useState(defaultSection); + const [newTokenLabel, setNewTokenLabel] = useState(''); + const [newTokenExpires, setNewTokenExpires] = useState(''); + const [formError, setFormError] = useState(null); + + const handleBackdropClick = useCallback(() => { + onClose?.(); + }, [onClose]); + + const handleInnerClick = useCallback((event) => { + event.stopPropagation(); + }, []); + + useEffect(() => { + if (!open) { + setActiveSection(defaultSection); + setNewTokenLabel(''); + setNewTokenExpires(''); + setFormError(null); + } + }, [open, defaultSection]); + + const formatDateTime = useCallback((value) => { + if (!value) { + return '—'; + } + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) { + return value; + } + return timestamp.toLocaleString(); + }, []); + + const handleRefresh = useCallback(() => { + onRefresh?.(); + }, [onRefresh]); + + const handleCopyToken = useCallback(() => { + if (!createdToken) { + return; + } + if (navigator?.clipboard?.writeText) { + navigator.clipboard.writeText(createdToken).catch(() => {}); + } + }, [createdToken]); + + const handleDismissSecret = useCallback(() => { + onDismissCreatedToken?.(); + }, [onDismissCreatedToken]); + + const handleCreateToken = useCallback( + async (event) => { + event.preventDefault(); + setFormError(null); + let normalizedLabel = newTokenLabel.trim(); + if (normalizedLabel.length === 0) { + normalizedLabel = undefined; + } + + let normalizedExpires; + if (newTokenExpires) { + const parsed = new Date(newTokenExpires); + if (Number.isNaN(parsed.getTime())) { + setFormError('Enter a valid expiration date.'); + return; + } + normalizedExpires = parsed.toISOString(); + } + + const result = await onCreate?.({ + label: normalizedLabel, + expires_at: normalizedExpires, + }); + + if (result !== false) { + setNewTokenLabel(''); + setNewTokenExpires(''); + setFormError(null); + } + }, + [newTokenExpires, newTokenLabel, onCreate], + ); + + const renderWebdavSection = useMemo(() => { + const hasTokens = Array.isArray(tokens) && tokens.length > 0; + + return ( +
+
+ +
+ + {createdToken ? ( +
+

+ Copy this token now; you will not be able to view it again after closing this window. +

+
{createdToken}
+
+ + +
+
+ ) : null} + +
+
+ + setNewTokenLabel(event.target.value)} + placeholder="Personal WebDAV token" + /> +
+
+ + setNewTokenExpires(event.target.value)} + /> +
+
+ +
+
+ {formError ?

{formError}

: null} + + {loading && !hasTokens ? ( +

Loading tokens…

+ ) : null} + + {!loading && !hasTokens ? ( +

No WebDAV tokens yet.

+ ) : null} + + {hasTokens ? ( + + + + + + + + + + + + + {tokens.map((token) => { + const isRevoked = Boolean(token?.revoked_at); + const scopes = Array.isArray(token?.scopes) + ? token.scopes.join(', ') + : '—'; + return ( + + + + + + + + + ); + })} + +
LabelCreatedLast usedExpiresScopesActions
{token.label || '—'}{formatDateTime(token.created_at)}{formatDateTime(token.last_used_at)}{formatDateTime(token.expires_at)}{scopes || '—'} + {isRevoked ? ( + Revoked + ) : ( + + )} +
+ ) : null} +
+ ); + }, [ + tokens, + loading, + createdToken, + creating, + deletingId, + newTokenLabel, + newTokenExpires, + formError, + formatDateTime, + handleCopyToken, + handleCreateToken, + handleRefresh, + onDelete, + handleDismissSecret, + ]); + + if (!open) { + return null; + } + + return ( +
+
+
+

Settings

+ +
+
+ +
+ {activeSection === 'webdav' ? renderWebdavSection :

Select a settings section.

} +
+
+
+
+ ); +}; + +export default SettingsModal; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index feaf3c7..092efa4 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -36,6 +36,18 @@ --shadow-faint: color-mix(in oklch, black 6%, transparent); --shadow-soft: color-mix(in oklch, black 12%, transparent); --shadow-medium: color-mix(in oklch, black 18%, transparent); + --shadow-strong: color-mix(in oklch, black 28%, transparent); + --shadow-deep: color-mix(in oklch, black 55%, transparent); + --shadow-pop: color-mix(in oklch, black 25%, transparent); + --outline-subtle: color-mix(in oklch, black 6%, transparent); + --overlay-dark: color-mix(in oklch, black 55%, transparent); + --overlay-dim: color-mix(in oklch, oklch(0.28 0.01 calc(265deg + var(--warmth))) 20%, transparent); + --overlay-darker: color-mix(in oklch, black 75%, transparent); + --surface-danger-subtle: color-mix(in oklch, var(--danger) 12%, transparent); + --overlay-accent-subtle: color-mix(in oklch, var(--accent) 12%, transparent); + --border-strong: color-mix(in oklch, var(--fg) 24%, transparent); + --surface-hover: color-mix(in oklch, white 10%, transparent); + --overlay-accent-strong: color-mix(in oklch, var(--accent) 24%, transparent); --overlay-backdrop: color-mix(in oklch, oklch(0.22 0.015 calc(265deg + var(--warmth))) 45%, transparent); --overlay-shadow: color-mix(in oklch, oklch(0.22 0.015 calc(265deg + var(--warmth))) 18%, transparent); @@ -57,9 +69,17 @@ --sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent); --selection-ring: oklch(0.78 0.16 calc(260deg + var(--warmth))); --sidebar-active-pill-border: color-mix(in oklch, var(--accent) 64%, transparent); + --link: var(--accent); + --link-visited: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%); + --preview-nav-bg: oklch(0.18 0.05 calc(270deg + var(--warmth))); + --preview-nav-bg-hover: oklch(0.14 0.05 calc(270deg + var(--warmth))); + --preview-nav-fg: var(--on-accent); + --text-on-dark: color-mix(in oklch, white 90%, transparent); + --surface-ink-soft: color-mix(in oklch, var(--fg) 8%, transparent); font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 15px; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; --detail-panel-width: 30em; --documents-grid-title-size: 0.8rem; @@ -86,7 +106,7 @@ a { } a:visited { - color: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%); + color: var(--link-visited); } a:hover, @@ -244,7 +264,7 @@ button.danger:hover:not([disabled]) { .preview-zoom-backdrop { position: fixed; inset: 0; - background: rgba(15, 23, 42, 0); + background: var(--overlay-backdrop); transition: background 0.25s ease, opacity 0.25s ease; display: flex; align-items: center; @@ -283,7 +303,7 @@ button.danger:hover:not([disabled]) { justify-content: center; max-width: 95vw; max-height: 95vh; - box-shadow: 0 32px 120px rgba(15, 23, 42, 0.55); + box-shadow: 0 32px 120px var(--shadow-deep); } .preview-zoom__scroll:focus { @@ -322,23 +342,23 @@ button.danger:hover:not([disabled]) { height: 2.8rem; border: none; border-radius: 999px; - background: rgba(0, 0, 0, 0.62); - color: #fff; + background: var(--preview-nav-bg); + color: var(--preview-nav-fg); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; z-index: 1; transition: background 0.15s ease, transform 0.15s ease; - box-shadow: 0 12px 28px rgba(15, 23, 42, 0.28); + box-shadow: 0 12px 28px var(--shadow-strong); } .preview-zoom__nav-button:hover { - background: rgba(0, 0, 0, 0.82); + background: var(--preview-nav-bg-hover); } .preview-zoom__nav-button:focus-visible { - outline: 2px solid var(--accent, #4f46e5); + outline: 2px solid var(--accent); outline-offset: 3px; } @@ -604,9 +624,9 @@ button.danger:hover:not([disabled]) { width: 64px; height: 64px; border-radius: 6px; - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25); + box-shadow: 0 6px 12px var(--shadow-pop); overflow: hidden; - background-color: rgba(32, 34, 40, 0.2); + background-color: var(--overlay-dim); display: flex; align-items: center; justify-content: center; @@ -668,7 +688,7 @@ button.danger:hover:not([disabled]) { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - color: rgba(255, 255, 255, 0.9); + color: var(--text-on-dark); } .document-drag-preview__count { @@ -676,12 +696,12 @@ button.danger:hover:not([disabled]) { bottom: 4px; right: 4px; background-color: var(--accent); - color: #fff; + color: var(--on-accent); border-radius: 999px; padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 600; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25); + box-shadow: 0 4px 8px var(--shadow-pop); pointer-events: none; } @@ -703,7 +723,7 @@ button.danger:hover:not([disabled]) { .preview-workspace__info { background: var(--surface-subtle); padding: 1rem; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06); + box-shadow: inset 0 0 0 1px var(--outline-subtle); } .preview-workspace__summary { @@ -1152,7 +1172,7 @@ button.danger:hover:not([disabled]) { } .sidebar__title-button:focus-visible { - outline: 2px solid var(--accent, #2563eb); + outline: 2px solid var(--accent); outline-offset: 2px; } @@ -1215,12 +1235,12 @@ button.danger:hover:not([disabled]) { .menu button.menu__item.active, .menu .menu__item.active { font-weight: 600; - color: var(--accent, #2563eb); + color: var(--accent); } .menu button.menu__item:focus-visible, .menu .menu__item:focus-visible { - outline: 2px solid var(--accent, #2563eb); + outline: 2px solid var(--accent); outline-offset: -2px; } @@ -1235,7 +1255,7 @@ button.danger:hover:not([disabled]) { display: flex; align-items: center; justify-content: center; - color: var(--accent, #2563eb); + color: var(--accent); } .menu__active-indicator { @@ -1888,7 +1908,7 @@ button.danger:hover:not([disabled]) { border: none; padding: 0; margin: 0; - color: var(--accent, #2563eb); + color: var(--accent); text-decoration: none; font: inherit; cursor: pointer; @@ -1939,7 +1959,7 @@ button.danger:hover:not([disabled]) { } .documents-panel tbody tr.document.tag-drop-target { - background: var(--accent-soft, rgba(63, 106, 216, 0.12)); + background: var(--accent-soft); box-shadow: inset 0 0 0 2px var(--accent); } @@ -2113,7 +2133,7 @@ button.danger:hover:not([disabled]) { margin: 0; white-space: pre-wrap; word-break: break-word; - font-family: var(--font-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace); + font-family: var(--font-mono); font-size: 0.85rem; line-height: 1.4; } @@ -2354,7 +2374,7 @@ button.danger:hover:not([disabled]) { } .preview-pane__nav-button:focus-visible { - outline: 2px solid var(--accent, #2684ff); + outline: 2px solid var(--accent); outline-offset: 2px; } @@ -2426,7 +2446,7 @@ button.danger:hover:not([disabled]) { border: 1px solid var(--border-muted, var(--border)); white-space: pre-wrap; word-break: break-word; - font-family: var(--font-mono, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace); + font-family: var(--font-mono); font-size: 0.82rem; line-height: 1.45; } @@ -2531,7 +2551,8 @@ form.inline { } .modal--panel { - width: min(80vw, 960px); + width: 80vw; + max-width: 960px; max-height: 90vh; padding: 0; border-radius: 12px; @@ -2539,6 +2560,175 @@ form.inline { gap: 0; } +.settings-modal__body { + display: grid; + grid-template-columns: 12rem 1fr; + gap: 1.5rem; + height: 90vh; + padding: 1.5rem; +} + +.settings-modal__sidebar { + border-right: 1px solid var(--border-muted, var(--border)); + padding-right: 1rem; +} + +.settings-modal__sidebar ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.settings-modal__sidebar button { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 0.4rem; + border: none; + background: none; + color: inherit; + font: inherit; + padding: 0.45rem 0.6rem; + border-radius: 0.35rem; + cursor: pointer; +} + +.settings-modal__sidebar button:hover, +.settings-modal__sidebar button:focus-visible { + background: var(--sidebar-hover-bg); +} + +.settings-modal__sidebar button.active { + background: var(--surface-soft, rgba(148, 163, 184, 0.18)); + font-weight: 600; +} + +.settings-modal__content { + overflow-y: auto; + padding-bottom: 1rem; +} + +.settings-section h4 { + margin-top: 0; +} + +.settings-section { + display: flex; + flex-direction: column; +} + +.settings-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + +.settings-actions .secondary { + min-width: 7rem; +} + +.settings-form { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; + margin-bottom: 0.75rem; +} + +.settings-form__field { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 14rem; +} + +.settings-form__field input[type='text'], +.settings-form__field input[type='datetime-local'] { + padding: 0.45rem 0.6rem; + border: 1px solid var(--border); + border-radius: 0.35rem; + background: var(--surface-soft, rgba(148, 163, 184, 0.12)); + color: inherit; +} + +.settings-form__actions { + display: flex; + gap: 0.5rem; +} + +.settings-form__error { + color: var(--danger, #d14343); + font-size: 0.85rem; + margin: 0 0 0.75rem; +} + +.settings-empty { + margin: 1rem 0; + color: var(--muted); +} + +.settings-table { + width: 100%; + border-collapse: collapse; + margin-top: 0.5rem; +} + +.settings-table th, +.settings-table td { + padding: 0.55rem 0.75rem; + border-bottom: 1px solid var(--border-muted, var(--border)); + text-align: left; + font-size: 0.9rem; +} + +.settings-table tr:last-child td { + border-bottom: none; +} + +.settings-table__actions { + white-space: nowrap; +} + +.settings-status { + color: var(--muted); + font-size: 0.9rem; +} + +.settings-table tr.is-revoked { + opacity: 0.65; +} + +.settings-notice { + border: 1px solid var(--accent); + background: rgba(37, 99, 235, 0.08); + padding: 0.9rem 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; +} + +.token-display { + background: rgba(15, 23, 42, 0.08); + padding: 0.5rem 0.65rem; + border-radius: 0.35rem; + font-family: var(--font-mono); + font-size: 0.95rem; + overflow-x: auto; +} + +.settings-notice__actions { + display: flex; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.settings-notice__actions button { + flex: 0 0 auto; +} + .panel-modal__header { display: flex; align-items: center;