+22
.sqlx/query-6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052.json
+22
.sqlx/query-6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT storage_key FROM blobs WHERE cid = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "storage_key",
9
+
"type_info": "Text"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
false
19
+
]
20
+
},
21
+
"hash": "6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052"
22
+
}
+14
.sqlx/query-d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12.json
+14
.sqlx/query-d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12.json
+2
frontend/src/App.svelte
+2
frontend/src/App.svelte
···
1
1
<script lang="ts">
2
2
import { getCurrentPath, navigate } from './lib/router.svelte'
3
3
import { initAuth, getAuthState } from './lib/auth.svelte'
4
+
import { initServerConfig } from './lib/serverConfig.svelte'
4
5
import { initI18n, _ } from './lib/i18n'
5
6
import { isLoading as i18nLoading } from 'svelte-i18n'
6
7
import Login from './routes/Login.svelte'
···
41
42
}
42
43
43
44
$effect(() => {
45
+
initServerConfig()
44
46
initAuth().then(({ oauthLoginCompleted }) => {
45
47
if (oauthLoginCompleted) {
46
48
navigate('/dashboard')
+55
frontend/src/lib/api.ts
+55
frontend/src/lib/api.ts
···
265
265
availableUserDomains: string[]
266
266
inviteCodeRequired: boolean
267
267
links?: { privacyPolicy?: string; termsOfService?: string }
268
+
version?: string
268
269
}> {
269
270
return xrpc('com.atproto.server.describeServer')
271
+
},
272
+
273
+
async listRepos(limit?: number): Promise<{
274
+
repos: Array<{ did: string; head: string; rev: string }>
275
+
cursor?: string
276
+
}> {
277
+
const params: Record<string, string> = {}
278
+
if (limit) params.limit = String(limit)
279
+
return xrpc('com.atproto.sync.listRepos', { params })
270
280
},
271
281
272
282
async getNotificationPrefs(token: string): Promise<{
···
323
333
blobStorageBytes: number
324
334
}> {
325
335
return xrpc('com.tranquil.admin.getServerStats', { token })
336
+
},
337
+
338
+
async getServerConfig(): Promise<{
339
+
serverName: string
340
+
primaryColor: string | null
341
+
primaryColorDark: string | null
342
+
secondaryColor: string | null
343
+
secondaryColorDark: string | null
344
+
logoCid: string | null
345
+
}> {
346
+
return xrpc('com.tranquil.server.getConfig')
347
+
},
348
+
349
+
async updateServerConfig(
350
+
token: string,
351
+
config: {
352
+
serverName?: string
353
+
primaryColor?: string
354
+
primaryColorDark?: string
355
+
secondaryColor?: string
356
+
secondaryColorDark?: string
357
+
logoCid?: string
358
+
}
359
+
): Promise<{ success: boolean }> {
360
+
return xrpc('com.tranquil.admin.updateServerConfig', {
361
+
method: 'POST',
362
+
token,
363
+
body: config,
364
+
})
365
+
},
366
+
367
+
async uploadBlob(token: string, file: File): Promise<{ blob: { $type: string; ref: { $link: string }; mimeType: string; size: number } }> {
368
+
const res = await fetch('/xrpc/com.atproto.repo.uploadBlob', {
369
+
method: 'POST',
370
+
headers: {
371
+
'Authorization': `Bearer ${token}`,
372
+
'Content-Type': file.type,
373
+
},
374
+
body: file,
375
+
})
376
+
if (!res.ok) {
377
+
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
378
+
throw new ApiError(res.status, err.error, err.message)
379
+
}
380
+
return res.json()
326
381
},
327
382
328
383
async changePassword(token: string, currentPassword: string, newPassword: string): Promise<void> {
+7
-1
frontend/src/lib/oauth.ts
+7
-1
frontend/src/lib/oauth.ts
···
75
75
client_id: clientId,
76
76
redirect_uri: redirectUri,
77
77
response_type: 'code',
78
-
scope: 'atproto transition:generic',
78
+
scope: [
79
+
'atproto',
80
+
'repo:*?action=create',
81
+
'repo:*?action=update',
82
+
'repo:*?action=delete',
83
+
'blob:*/*',
84
+
].join(' '),
79
85
state: state,
80
86
code_challenge: codeChallenge,
81
87
code_challenge_method: 'S256',
+123
frontend/src/lib/serverConfig.svelte.ts
+123
frontend/src/lib/serverConfig.svelte.ts
···
1
+
import { api } from './api'
2
+
3
+
interface ServerConfigState {
4
+
serverName: string | null
5
+
primaryColor: string | null
6
+
primaryColorDark: string | null
7
+
secondaryColor: string | null
8
+
secondaryColorDark: string | null
9
+
hasLogo: boolean
10
+
loading: boolean
11
+
}
12
+
13
+
let state = $state<ServerConfigState>({
14
+
serverName: null,
15
+
primaryColor: null,
16
+
primaryColorDark: null,
17
+
secondaryColor: null,
18
+
secondaryColorDark: null,
19
+
hasLogo: false,
20
+
loading: true,
21
+
})
22
+
23
+
let initialized = false
24
+
let darkModeQuery: MediaQueryList | null = null
25
+
26
+
function isDarkMode(): boolean {
27
+
return darkModeQuery?.matches ?? false
28
+
}
29
+
30
+
function applyColors() {
31
+
const root = document.documentElement
32
+
const dark = isDarkMode()
33
+
34
+
if (dark) {
35
+
if (state.primaryColorDark) {
36
+
root.style.setProperty('--accent', state.primaryColorDark)
37
+
} else {
38
+
root.style.removeProperty('--accent')
39
+
}
40
+
if (state.secondaryColorDark) {
41
+
root.style.setProperty('--secondary', state.secondaryColorDark)
42
+
} else {
43
+
root.style.removeProperty('--secondary')
44
+
}
45
+
} else {
46
+
if (state.primaryColor) {
47
+
root.style.setProperty('--accent', state.primaryColor)
48
+
} else {
49
+
root.style.removeProperty('--accent')
50
+
}
51
+
if (state.secondaryColor) {
52
+
root.style.setProperty('--secondary', state.secondaryColor)
53
+
} else {
54
+
root.style.removeProperty('--secondary')
55
+
}
56
+
}
57
+
}
58
+
59
+
function setFavicon(hasLogo: boolean) {
60
+
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']")
61
+
if (hasLogo) {
62
+
if (!link) {
63
+
link = document.createElement('link')
64
+
link.rel = 'icon'
65
+
document.head.appendChild(link)
66
+
}
67
+
link.href = '/logo'
68
+
} else if (link) {
69
+
link.remove()
70
+
}
71
+
}
72
+
73
+
export async function initServerConfig(): Promise<void> {
74
+
if (initialized) return
75
+
initialized = true
76
+
77
+
darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
78
+
darkModeQuery.addEventListener('change', applyColors)
79
+
80
+
try {
81
+
const config = await api.getServerConfig()
82
+
state.serverName = config.serverName
83
+
state.primaryColor = config.primaryColor
84
+
state.primaryColorDark = config.primaryColorDark
85
+
state.secondaryColor = config.secondaryColor
86
+
state.secondaryColorDark = config.secondaryColorDark
87
+
state.hasLogo = !!config.logoCid
88
+
document.title = config.serverName
89
+
applyColors()
90
+
setFavicon(state.hasLogo)
91
+
} catch {
92
+
state.serverName = null
93
+
} finally {
94
+
state.loading = false
95
+
}
96
+
}
97
+
98
+
export function getServerConfigState() {
99
+
return state
100
+
}
101
+
102
+
export function setServerName(name: string) {
103
+
state.serverName = name
104
+
document.title = name
105
+
}
106
+
107
+
export function setColors(colors: {
108
+
primaryColor?: string | null
109
+
primaryColorDark?: string | null
110
+
secondaryColor?: string | null
111
+
secondaryColorDark?: string | null
112
+
}) {
113
+
if (colors.primaryColor !== undefined) state.primaryColor = colors.primaryColor
114
+
if (colors.primaryColorDark !== undefined) state.primaryColorDark = colors.primaryColorDark
115
+
if (colors.secondaryColor !== undefined) state.secondaryColor = colors.secondaryColor
116
+
if (colors.secondaryColorDark !== undefined) state.secondaryColorDark = colors.secondaryColorDark
117
+
applyColors()
118
+
}
119
+
120
+
export function setHasLogo(hasLogo: boolean) {
121
+
state.hasLogo = hasLogo
122
+
setFavicon(hasLogo)
123
+
}
+10
-2
frontend/src/locales/en.json
+10
-2
frontend/src/locales/en.json
···
14
14
"expires": "Expires",
15
15
"name": "Name",
16
16
"dashboard": "Dashboard",
17
-
"backToDashboard": "← Dashboard"
17
+
"backToDashboard": "← Dashboard",
18
+
"copied": "Copied!",
19
+
"copyToClipboard": "Copy to Clipboard"
18
20
},
19
21
"login": {
20
22
"title": "Sign In",
···
45
47
"register": {
46
48
"title": "Create Account",
47
49
"subtitle": "Create a new account on this PDS",
50
+
"migrateTitle": "Already have a Bluesky account?",
51
+
"migrateDescription": "You can migrate your existing account to this PDS instead of creating a new one. Your followers, posts, and identity will come with you.",
52
+
"migrateLink": "Migrate with PDS Moover",
48
53
"handle": "Handle",
49
54
"handlePlaceholder": "yourname",
50
55
"handleHint": "Your full handle will be: @{handle}",
···
226
231
"revoke": "Revoke",
227
232
"revoking": "Revoking...",
228
233
"creating": "Creating...",
229
-
"revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account."
234
+
"revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.",
235
+
"saveWarningTitle": "Important: Save this app password!",
236
+
"saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.",
237
+
"acknowledgeLabel": "I have saved my app password in a secure location"
230
238
},
231
239
"sessions": {
232
240
"title": "Active Sessions",
+10
-2
frontend/src/locales/fi.json
+10
-2
frontend/src/locales/fi.json
···
14
14
"expires": "Vanhenee",
15
15
"name": "Nimi",
16
16
"dashboard": "Hallintapaneeli",
17
-
"backToDashboard": "← Hallintapaneeli"
17
+
"backToDashboard": "← Hallintapaneeli",
18
+
"copied": "Kopioitu!",
19
+
"copyToClipboard": "Kopioi"
18
20
},
19
21
"login": {
20
22
"title": "Kirjaudu sisään",
···
45
47
"register": {
46
48
"title": "Luo tili",
47
49
"subtitle": "Luo uusi tili tälle PDS:lle",
50
+
"migrateTitle": "Onko sinulla jo Bluesky-tili?",
51
+
"migrateDescription": "Voit siirtää olemassa olevan tilisi tälle PDS:lle uuden luomisen sijaan. Seuraajasi, julkaisusi ja identiteettisi siirtyvät mukana.",
52
+
"migrateLink": "Siirrä PDS Mooverilla",
48
53
"handle": "Käyttäjänimi",
49
54
"handlePlaceholder": "nimesi",
50
55
"handleHint": "Täydellinen käyttäjänimesi on: @{handle}",
···
226
231
"revoke": "Peruuta",
227
232
"revoking": "Peruutetaan...",
228
233
"creating": "Luodaan...",
229
-
"revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi."
234
+
"revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.",
235
+
"saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!",
236
+
"saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.",
237
+
"acknowledgeLabel": "Olen tallentanut sovelluksen salasanani turvalliseen paikkaan"
230
238
},
231
239
"sessions": {
232
240
"title": "Aktiiviset istunnot",
+10
-2
frontend/src/locales/ja.json
+10
-2
frontend/src/locales/ja.json
···
14
14
"expires": "有効期限",
15
15
"name": "名前",
16
16
"dashboard": "ダッシュボード",
17
-
"backToDashboard": "← ダッシュボード"
17
+
"backToDashboard": "← ダッシュボード",
18
+
"copied": "コピーしました!",
19
+
"copyToClipboard": "クリップボードにコピー"
18
20
},
19
21
"login": {
20
22
"title": "サインイン",
···
45
47
"register": {
46
48
"title": "アカウント作成",
47
49
"subtitle": "この PDS で新規アカウントを作成",
50
+
"migrateTitle": "すでにBlueskyアカウントをお持ちですか?",
51
+
"migrateDescription": "新しいアカウントを作成する代わりに、既存のアカウントをこのPDSに移行できます。フォロワー、投稿、IDも一緒に移行されます。",
52
+
"migrateLink": "PDS Mooverで移行する",
48
53
"handle": "ハンドル",
49
54
"handlePlaceholder": "あなたの名前",
50
55
"handleHint": "完全なハンドル: @{handle}",
···
226
231
"revoke": "取り消す",
227
232
"revoking": "取り消し中...",
228
233
"creating": "作成中...",
229
-
"revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。"
234
+
"revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。",
235
+
"saveWarningTitle": "重要: このアプリパスワードを保存してください!",
236
+
"saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。",
237
+
"acknowledgeLabel": "アプリパスワードを安全な場所に保存しました"
230
238
},
231
239
"sessions": {
232
240
"title": "アクティブセッション",
+10
-2
frontend/src/locales/ko.json
+10
-2
frontend/src/locales/ko.json
···
14
14
"expires": "만료일",
15
15
"name": "이름",
16
16
"dashboard": "대시보드",
17
-
"backToDashboard": "← 대시보드"
17
+
"backToDashboard": "← 대시보드",
18
+
"copied": "복사됨!",
19
+
"copyToClipboard": "클립보드에 복사"
18
20
},
19
21
"login": {
20
22
"title": "로그인",
···
45
47
"register": {
46
48
"title": "계정 만들기",
47
49
"subtitle": "이 PDS에 새 계정을 만듭니다",
50
+
"migrateTitle": "이미 Bluesky 계정이 있으신가요?",
51
+
"migrateDescription": "새 계정을 만드는 대신 기존 계정을 이 PDS로 마이그레이션할 수 있습니다. 팔로워, 게시물, ID가 함께 이전됩니다.",
52
+
"migrateLink": "PDS Moover로 마이그레이션",
48
53
"handle": "핸들",
49
54
"handlePlaceholder": "사용자 이름",
50
55
"handleHint": "전체 핸들: @{handle}",
···
226
231
"revoke": "취소",
227
232
"revoking": "취소 중...",
228
233
"creating": "생성 중...",
229
-
"revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다."
234
+
"revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.",
235
+
"saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!",
236
+
"saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.",
237
+
"acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다"
230
238
},
231
239
"sessions": {
232
240
"title": "활성 세션",
+10
-2
frontend/src/locales/sv.json
+10
-2
frontend/src/locales/sv.json
···
14
14
"expires": "Upphör",
15
15
"name": "Namn",
16
16
"dashboard": "Kontrollpanel",
17
-
"backToDashboard": "← Kontrollpanel"
17
+
"backToDashboard": "← Kontrollpanel",
18
+
"copied": "Kopierat!",
19
+
"copyToClipboard": "Kopiera"
18
20
},
19
21
"login": {
20
22
"title": "Logga in",
···
45
47
"register": {
46
48
"title": "Skapa konto",
47
49
"subtitle": "Skapa ett nytt konto på denna PDS",
50
+
"migrateTitle": "Har du redan ett Bluesky-konto?",
51
+
"migrateDescription": "Du kan flytta ditt befintliga konto till denna PDS istället för att skapa ett nytt. Dina följare, inlägg och identitet följer med.",
52
+
"migrateLink": "Flytta med PDS Moover",
48
53
"handle": "Användarnamn",
49
54
"handlePlaceholder": "dittnamn",
50
55
"handleHint": "Ditt fullständiga användarnamn blir: @{handle}",
···
226
231
"revoke": "Återkalla",
227
232
"revoking": "Återkallar...",
228
233
"creating": "Skapar...",
229
-
"revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto."
234
+
"revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.",
235
+
"saveWarningTitle": "Viktigt: Spara detta applösenord!",
236
+
"saveWarningMessage": "Detta lösenord krävs för att logga in i appar som inte stöder passkeys eller OAuth. Du ser det bara en gång.",
237
+
"acknowledgeLabel": "Jag har sparat mitt applösenord på en säker plats"
230
238
},
231
239
"sessions": {
232
240
"title": "Aktiva sessioner",
+10
-2
frontend/src/locales/zh.json
+10
-2
frontend/src/locales/zh.json
···
14
14
"expires": "过期时间",
15
15
"name": "名称",
16
16
"dashboard": "控制台",
17
-
"backToDashboard": "← 返回控制台"
17
+
"backToDashboard": "← 返回控制台",
18
+
"copied": "已复制!",
19
+
"copyToClipboard": "复制"
18
20
},
19
21
"login": {
20
22
"title": "登录",
···
45
47
"register": {
46
48
"title": "创建账户",
47
49
"subtitle": "在此 PDS 上创建新账户",
50
+
"migrateTitle": "已有 Bluesky 账户?",
51
+
"migrateDescription": "您可以将现有账户迁移到此 PDS,而无需创建新账户。您的关注者、帖子和身份都会一起迁移。",
52
+
"migrateLink": "使用 PDS Moover 迁移",
48
53
"handle": "用户名",
49
54
"handlePlaceholder": "您的用户名",
50
55
"handleHint": "您的完整用户名将是:@{handle}",
···
226
231
"revoke": "撤销",
227
232
"revoking": "撤销中...",
228
233
"creating": "创建中...",
229
-
"revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。"
234
+
"revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。",
235
+
"saveWarningTitle": "重要:请保存此应用专用密码!",
236
+
"saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。",
237
+
"acknowledgeLabel": "我已将应用专用密码保存在安全的地方"
230
238
},
231
239
"sessions": {
232
240
"title": "登录会话",
+365
frontend/src/routes/Admin.svelte
+365
frontend/src/routes/Admin.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState } from '../lib/auth.svelte'
3
+
import { setServerName as setGlobalServerName, setColors as setGlobalColors, setHasLogo as setGlobalHasLogo } from '../lib/serverConfig.svelte'
3
4
import { navigate } from '../lib/router.svelte'
4
5
import { api, ApiError } from '../lib/api'
5
6
import { _ } from '../lib/i18n'
···
50
51
} | null>(null)
51
52
let userDetailLoading = $state(false)
52
53
let userActionLoading = $state(false)
54
+
let serverName = $state('')
55
+
let serverNameInput = $state('')
56
+
let primaryColor = $state('')
57
+
let primaryColorInput = $state('')
58
+
let primaryColorDark = $state('')
59
+
let primaryColorDarkInput = $state('')
60
+
let secondaryColor = $state('')
61
+
let secondaryColorInput = $state('')
62
+
let secondaryColorDark = $state('')
63
+
let secondaryColorDarkInput = $state('')
64
+
let logoCid = $state<string | null>(null)
65
+
let originalLogoCid = $state<string | null>(null)
66
+
let logoFile = $state<File | null>(null)
67
+
let logoPreview = $state<string | null>(null)
68
+
let serverConfigLoading = $state(false)
69
+
let serverConfigError = $state<string | null>(null)
70
+
let serverConfigSuccess = $state(false)
53
71
$effect(() => {
54
72
if (!auth.loading && !auth.session) {
55
73
navigate('/login')
···
60
78
$effect(() => {
61
79
if (auth.session?.isAdmin) {
62
80
loadStats()
81
+
loadServerConfig()
63
82
}
64
83
})
84
+
async function loadServerConfig() {
85
+
try {
86
+
const config = await api.getServerConfig()
87
+
serverName = config.serverName
88
+
serverNameInput = config.serverName
89
+
primaryColor = config.primaryColor || ''
90
+
primaryColorInput = config.primaryColor || ''
91
+
primaryColorDark = config.primaryColorDark || ''
92
+
primaryColorDarkInput = config.primaryColorDark || ''
93
+
secondaryColor = config.secondaryColor || ''
94
+
secondaryColorInput = config.secondaryColor || ''
95
+
secondaryColorDark = config.secondaryColorDark || ''
96
+
secondaryColorDarkInput = config.secondaryColorDark || ''
97
+
logoCid = config.logoCid
98
+
originalLogoCid = config.logoCid
99
+
if (config.logoCid) {
100
+
logoPreview = '/logo'
101
+
}
102
+
} catch (e) {
103
+
serverConfigError = e instanceof ApiError ? e.message : 'Failed to load server config'
104
+
}
105
+
}
106
+
async function saveServerConfig(e: Event) {
107
+
e.preventDefault()
108
+
if (!auth.session) return
109
+
serverConfigLoading = true
110
+
serverConfigError = null
111
+
serverConfigSuccess = false
112
+
try {
113
+
let newLogoCid = logoCid
114
+
if (logoFile) {
115
+
const result = await api.uploadBlob(auth.session.accessJwt, logoFile)
116
+
newLogoCid = result.blob.ref.$link
117
+
}
118
+
await api.updateServerConfig(auth.session.accessJwt, {
119
+
serverName: serverNameInput,
120
+
primaryColor: primaryColorInput,
121
+
primaryColorDark: primaryColorDarkInput,
122
+
secondaryColor: secondaryColorInput,
123
+
secondaryColorDark: secondaryColorDarkInput,
124
+
logoCid: newLogoCid ?? '',
125
+
})
126
+
serverName = serverNameInput
127
+
primaryColor = primaryColorInput
128
+
primaryColorDark = primaryColorDarkInput
129
+
secondaryColor = secondaryColorInput
130
+
secondaryColorDark = secondaryColorDarkInput
131
+
logoCid = newLogoCid
132
+
originalLogoCid = newLogoCid
133
+
logoFile = null
134
+
setGlobalServerName(serverNameInput)
135
+
setGlobalColors({
136
+
primaryColor: primaryColorInput || null,
137
+
primaryColorDark: primaryColorDarkInput || null,
138
+
secondaryColor: secondaryColorInput || null,
139
+
secondaryColorDark: secondaryColorDarkInput || null,
140
+
})
141
+
setGlobalHasLogo(!!newLogoCid)
142
+
serverConfigSuccess = true
143
+
setTimeout(() => { serverConfigSuccess = false }, 3000)
144
+
} catch (e) {
145
+
serverConfigError = e instanceof ApiError ? e.message : 'Failed to save server config'
146
+
} finally {
147
+
serverConfigLoading = false
148
+
}
149
+
}
150
+
151
+
function handleLogoChange(e: Event) {
152
+
const input = e.target as HTMLInputElement
153
+
const file = input.files?.[0]
154
+
if (file) {
155
+
logoFile = file
156
+
logoPreview = URL.createObjectURL(file)
157
+
}
158
+
}
159
+
160
+
function removeLogo() {
161
+
logoFile = null
162
+
logoCid = null
163
+
logoPreview = null
164
+
}
165
+
166
+
function hasConfigChanges(): boolean {
167
+
const logoChanged = logoFile !== null || logoCid !== originalLogoCid
168
+
return serverNameInput !== serverName ||
169
+
primaryColorInput !== primaryColor ||
170
+
primaryColorDarkInput !== primaryColorDark ||
171
+
secondaryColorInput !== secondaryColor ||
172
+
secondaryColorDarkInput !== secondaryColorDark ||
173
+
logoChanged
174
+
}
65
175
async function loadStats() {
66
176
if (!auth.session) return
67
177
loading = true
···
201
311
{#if error}
202
312
<div class="message error">{error}</div>
203
313
{/if}
314
+
<section>
315
+
<h2>Server Configuration</h2>
316
+
<form class="config-form" onsubmit={saveServerConfig}>
317
+
<div class="form-group">
318
+
<label for="serverName">Server Name</label>
319
+
<input
320
+
type="text"
321
+
id="serverName"
322
+
bind:value={serverNameInput}
323
+
placeholder="My PDS"
324
+
maxlength="100"
325
+
disabled={serverConfigLoading}
326
+
/>
327
+
<span class="help-text">Displayed in the browser tab and other places</span>
328
+
</div>
329
+
330
+
<div class="form-group">
331
+
<label for="serverLogo">Server Logo</label>
332
+
<div class="logo-upload">
333
+
{#if logoPreview}
334
+
<div class="logo-preview">
335
+
<img src={logoPreview} alt="Logo preview" />
336
+
<button type="button" class="remove-logo" onclick={removeLogo} disabled={serverConfigLoading}>Remove</button>
337
+
</div>
338
+
{:else}
339
+
<input
340
+
type="file"
341
+
id="serverLogo"
342
+
accept="image/*"
343
+
onchange={handleLogoChange}
344
+
disabled={serverConfigLoading}
345
+
/>
346
+
{/if}
347
+
</div>
348
+
<span class="help-text">Used as favicon and shown in the navbar</span>
349
+
</div>
350
+
351
+
<h3 class="subsection-title">Theme Colors</h3>
352
+
<p class="theme-hint">Leave blank to use default colors.</p>
353
+
354
+
<div class="color-grid">
355
+
<div class="color-group">
356
+
<label for="primaryColor">Primary (Light Mode)</label>
357
+
<div class="color-input-row">
358
+
<input
359
+
type="color"
360
+
bind:value={primaryColorInput}
361
+
disabled={serverConfigLoading}
362
+
/>
363
+
<input
364
+
type="text"
365
+
id="primaryColor"
366
+
bind:value={primaryColorInput}
367
+
placeholder="#2c00ff (default)"
368
+
disabled={serverConfigLoading}
369
+
/>
370
+
</div>
371
+
</div>
372
+
<div class="color-group">
373
+
<label for="primaryColorDark">Primary (Dark Mode)</label>
374
+
<div class="color-input-row">
375
+
<input
376
+
type="color"
377
+
bind:value={primaryColorDarkInput}
378
+
disabled={serverConfigLoading}
379
+
/>
380
+
<input
381
+
type="text"
382
+
id="primaryColorDark"
383
+
bind:value={primaryColorDarkInput}
384
+
placeholder="#7b6bff (default)"
385
+
disabled={serverConfigLoading}
386
+
/>
387
+
</div>
388
+
</div>
389
+
<div class="color-group">
390
+
<label for="secondaryColor">Secondary (Light Mode)</label>
391
+
<div class="color-input-row">
392
+
<input
393
+
type="color"
394
+
bind:value={secondaryColorInput}
395
+
disabled={serverConfigLoading}
396
+
/>
397
+
<input
398
+
type="text"
399
+
id="secondaryColor"
400
+
bind:value={secondaryColorInput}
401
+
placeholder="#ff2400 (default)"
402
+
disabled={serverConfigLoading}
403
+
/>
404
+
</div>
405
+
</div>
406
+
<div class="color-group">
407
+
<label for="secondaryColorDark">Secondary (Dark Mode)</label>
408
+
<div class="color-input-row">
409
+
<input
410
+
type="color"
411
+
bind:value={secondaryColorDarkInput}
412
+
disabled={serverConfigLoading}
413
+
/>
414
+
<input
415
+
type="text"
416
+
id="secondaryColorDark"
417
+
bind:value={secondaryColorDarkInput}
418
+
placeholder="#ff6b5b (default)"
419
+
disabled={serverConfigLoading}
420
+
/>
421
+
</div>
422
+
</div>
423
+
</div>
424
+
425
+
{#if serverConfigError}
426
+
<div class="message error">{serverConfigError}</div>
427
+
{/if}
428
+
{#if serverConfigSuccess}
429
+
<div class="message success">Server configuration saved</div>
430
+
{/if}
431
+
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
432
+
{serverConfigLoading ? 'Saving...' : 'Save Configuration'}
433
+
</button>
434
+
</form>
435
+
</section>
204
436
{#if stats}
205
437
<section>
206
438
<h2>Server Statistics</h2>
···
453
685
background: var(--error-bg);
454
686
border: 1px solid var(--error-border);
455
687
color: var(--error-text);
688
+
}
689
+
690
+
.message.success {
691
+
background: var(--success-bg);
692
+
border: 1px solid var(--success-border);
693
+
color: var(--success-text);
694
+
}
695
+
696
+
.config-form {
697
+
max-width: 500px;
698
+
}
699
+
700
+
.form-group {
701
+
margin-bottom: var(--space-4);
702
+
}
703
+
704
+
.form-group label {
705
+
display: block;
706
+
font-weight: var(--font-medium);
707
+
margin-bottom: var(--space-2);
708
+
font-size: var(--text-sm);
709
+
}
710
+
711
+
.form-group input {
712
+
width: 100%;
713
+
padding: var(--space-2) var(--space-3);
714
+
border: 1px solid var(--border-color);
715
+
border-radius: var(--radius-md);
716
+
font-size: var(--text-sm);
717
+
background: var(--bg-input);
718
+
color: var(--text-primary);
719
+
}
720
+
721
+
.form-group input:focus {
722
+
outline: none;
723
+
border-color: var(--accent);
724
+
}
725
+
726
+
.help-text {
727
+
display: block;
728
+
font-size: var(--text-xs);
729
+
color: var(--text-secondary);
730
+
margin-top: var(--space-1);
731
+
}
732
+
733
+
.config-form button {
734
+
padding: var(--space-2) var(--space-4);
735
+
background: var(--accent);
736
+
color: var(--text-inverse);
737
+
border: none;
738
+
border-radius: var(--radius-md);
739
+
cursor: pointer;
740
+
font-size: var(--text-sm);
741
+
}
742
+
743
+
.config-form button:hover:not(:disabled) {
744
+
background: var(--accent-hover);
745
+
}
746
+
747
+
.config-form button:disabled {
748
+
opacity: 0.6;
749
+
cursor: not-allowed;
750
+
}
751
+
752
+
.subsection-title {
753
+
font-size: var(--text-sm);
754
+
font-weight: var(--font-semibold);
755
+
color: var(--text-primary);
756
+
margin: var(--space-5) 0 var(--space-2) 0;
757
+
padding-top: var(--space-4);
758
+
border-top: 1px solid var(--border-color);
759
+
}
760
+
761
+
.theme-hint {
762
+
font-size: var(--text-xs);
763
+
color: var(--text-secondary);
764
+
margin-bottom: var(--space-4);
765
+
}
766
+
767
+
.color-grid {
768
+
display: grid;
769
+
grid-template-columns: 1fr 1fr;
770
+
gap: var(--space-4);
771
+
margin-bottom: var(--space-4);
772
+
}
773
+
774
+
@media (max-width: 500px) {
775
+
.color-grid {
776
+
grid-template-columns: 1fr;
777
+
}
778
+
}
779
+
780
+
.color-group label {
781
+
display: block;
782
+
font-size: var(--text-xs);
783
+
font-weight: var(--font-medium);
784
+
color: var(--text-secondary);
785
+
margin-bottom: var(--space-1);
786
+
}
787
+
788
+
.color-group input[type="text"] {
789
+
width: 100%;
790
+
}
791
+
792
+
.logo-upload {
793
+
margin-top: var(--space-2);
794
+
}
795
+
796
+
.logo-preview {
797
+
display: flex;
798
+
align-items: center;
799
+
gap: var(--space-3);
800
+
}
801
+
802
+
.logo-preview img {
803
+
width: 48px;
804
+
height: 48px;
805
+
object-fit: contain;
806
+
border-radius: var(--radius-md);
807
+
border: 1px solid var(--border-color);
808
+
background: var(--bg-input);
809
+
}
810
+
811
+
.remove-logo {
812
+
background: transparent;
813
+
color: var(--error-text);
814
+
border: 1px solid var(--error-border);
815
+
padding: var(--space-1) var(--space-2);
816
+
font-size: var(--text-xs);
817
+
}
818
+
819
+
.remove-logo:hover:not(:disabled) {
820
+
background: var(--error-bg);
456
821
}
457
822
458
823
section {
+79
-17
frontend/src/routes/AppPasswords.svelte
+79
-17
frontend/src/routes/AppPasswords.svelte
···
11
11
let newPasswordName = $state('')
12
12
let creating = $state(false)
13
13
let createdPassword = $state<{ name: string; password: string } | null>(null)
14
+
let passwordCopied = $state(false)
15
+
let passwordAcknowledged = $state(false)
14
16
let revoking = $state<string | null>(null)
15
17
$effect(() => {
16
18
if (!auth.loading && !auth.session) {
···
67
69
revoking = null
68
70
}
69
71
}
72
+
function copyPassword() {
73
+
if (createdPassword) {
74
+
navigator.clipboard.writeText(createdPassword.password)
75
+
passwordCopied = true
76
+
}
77
+
}
70
78
function dismissCreated() {
71
79
createdPassword = null
80
+
passwordCopied = false
81
+
passwordAcknowledged = false
72
82
}
73
83
</script>
74
84
<div class="page">
···
84
94
{/if}
85
95
{#if createdPassword}
86
96
<div class="created-password">
87
-
<h3>{$_('appPasswords.created')}</h3>
88
-
<p>{$_('appPasswords.createdMessage')}</p>
97
+
<div class="warning-box">
98
+
<strong>{$_('appPasswords.saveWarningTitle')}</strong>
99
+
<p>{$_('appPasswords.saveWarningMessage')}</p>
100
+
</div>
89
101
<div class="password-display">
90
-
<code>{createdPassword.password}</code>
102
+
<div class="password-label">{$_('common.name')}: <strong>{createdPassword.name}</strong></div>
103
+
<code class="password-code">{createdPassword.password}</code>
104
+
<button type="button" class="copy-btn" onclick={copyPassword}>
105
+
{passwordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
106
+
</button>
91
107
</div>
92
-
<p class="password-name">{$_('common.name')}: {createdPassword.name}</p>
93
-
<button onclick={dismissCreated}>{$_('common.done')}</button>
108
+
<label class="checkbox-label">
109
+
<input type="checkbox" bind:checked={passwordAcknowledged} />
110
+
<span>{$_('appPasswords.acknowledgeLabel')}</span>
111
+
</label>
112
+
<button onclick={dismissCreated} disabled={!passwordAcknowledged}>{$_('common.done')}</button>
94
113
</div>
95
114
{/if}
96
115
<section class="create-section">
···
175
194
}
176
195
177
196
.created-password {
197
+
display: flex;
198
+
flex-direction: column;
199
+
gap: var(--space-4);
178
200
padding: var(--space-6);
179
-
background: var(--success-bg);
180
-
border: 1px solid var(--success-border);
201
+
background: var(--bg-secondary);
202
+
border: 1px solid var(--border-color);
181
203
border-radius: var(--radius-xl);
182
204
margin-bottom: var(--space-7);
183
205
}
184
206
185
-
.created-password h3 {
186
-
margin: 0 0 var(--space-2) 0;
187
-
color: var(--success-text);
207
+
.warning-box {
208
+
padding: var(--space-5);
209
+
background: var(--warning-bg);
210
+
border: 1px solid var(--warning-border);
211
+
border-radius: var(--radius-lg);
212
+
font-size: var(--text-sm);
213
+
}
214
+
215
+
.warning-box strong {
216
+
display: block;
217
+
margin-bottom: var(--space-2);
218
+
color: var(--warning-text);
219
+
}
220
+
221
+
.warning-box p {
222
+
margin: 0;
223
+
color: var(--warning-text);
188
224
}
189
225
190
226
.password-display {
191
227
background: var(--bg-card);
192
-
padding: var(--space-4);
193
-
border-radius: var(--radius-md);
194
-
margin: var(--space-4) 0;
228
+
border: 2px solid var(--accent);
229
+
border-radius: var(--radius-xl);
230
+
padding: var(--space-6);
231
+
text-align: center;
195
232
}
196
233
197
-
.password-display code {
234
+
.password-label {
235
+
font-size: var(--text-sm);
236
+
color: var(--text-secondary);
237
+
margin-bottom: var(--space-4);
238
+
}
239
+
240
+
.password-code {
241
+
display: block;
198
242
font-size: var(--text-xl);
199
243
font-family: ui-monospace, monospace;
244
+
letter-spacing: 0.1em;
245
+
padding: var(--space-5);
246
+
background: var(--bg-input);
247
+
border-radius: var(--radius-md);
248
+
margin-bottom: var(--space-4);
249
+
user-select: all;
200
250
word-break: break-all;
201
251
}
202
252
203
-
.password-name {
204
-
color: var(--text-secondary);
253
+
.copy-btn {
254
+
padding: var(--space-3) var(--space-5);
205
255
font-size: var(--text-sm);
206
-
margin-bottom: var(--space-4);
256
+
}
257
+
258
+
.checkbox-label {
259
+
display: flex;
260
+
align-items: center;
261
+
gap: var(--space-3);
262
+
cursor: pointer;
263
+
font-weight: var(--font-normal);
264
+
}
265
+
266
+
.checkbox-label input[type="checkbox"] {
267
+
width: auto;
268
+
padding: 0;
207
269
}
208
270
209
271
section {
+67
-6
frontend/src/routes/Home.svelte
+67
-6
frontend/src/routes/Home.svelte
···
2
2
import { onMount } from 'svelte'
3
3
import { _ } from '../lib/i18n'
4
4
import { getAuthState } from '../lib/auth.svelte'
5
+
import { getServerConfigState } from '../lib/serverConfig.svelte'
6
+
import { api } from '../lib/api'
5
7
6
8
const auth = getAuthState()
9
+
const serverConfig = getServerConfigState()
7
10
const sourceUrl = 'https://tangled.org/lewis.moe/bspds-sandbox'
8
11
12
+
let pdsHostname = $state<string | null>(null)
13
+
let pdsVersion = $state<string | null>(null)
14
+
let userCount = $state<number | null>(null)
15
+
9
16
onMount(() => {
17
+
api.describeServer().then(info => {
18
+
if (info.availableUserDomains?.length) {
19
+
pdsHostname = info.availableUserDomains[0]
20
+
}
21
+
if (info.version) {
22
+
pdsVersion = info.version
23
+
}
24
+
}).catch(() => {})
25
+
26
+
api.listRepos(1000).then(data => {
27
+
userCount = data.repos.length
28
+
}).catch(() => {})
29
+
10
30
const pattern = document.getElementById('dotPattern')
11
31
if (!pattern) return
12
32
···
65
85
<div class="pattern-fade"></div>
66
86
67
87
<nav>
68
-
<span class="brand">Tranquil PDS</span>
69
-
<span class="nav-meta">0.1.0</span>
88
+
<div class="nav-left">
89
+
{#if serverConfig.hasLogo}
90
+
<img src="/logo" alt="Logo" class="nav-logo" />
91
+
{/if}
92
+
{#if pdsHostname}
93
+
<span class="hostname">{pdsHostname}</span>
94
+
{#if userCount !== null}
95
+
<span class="user-count">{userCount} {userCount === 1 ? 'user' : 'users'}</span>
96
+
{/if}
97
+
{:else}
98
+
<span class="hostname placeholder">loading...</span>
99
+
{/if}
100
+
</div>
101
+
<span class="nav-meta">{pdsVersion || ''}</span>
70
102
</nav>
71
103
72
104
<div class="home">
···
139
171
140
172
<footer class="site-footer">
141
173
<span>Open Source</span>
142
-
<span>Made with care</span>
174
+
<span>Made with patience</span>
143
175
</footer>
144
176
</div>
145
177
···
209
241
align-items: center;
210
242
}
211
243
212
-
.brand {
244
+
.nav-left {
245
+
display: flex;
246
+
align-items: center;
247
+
gap: var(--space-3);
248
+
}
249
+
250
+
.nav-logo {
251
+
height: 28px;
252
+
width: auto;
253
+
object-fit: contain;
254
+
border-radius: var(--radius-sm);
255
+
}
256
+
257
+
.hostname {
213
258
font-weight: var(--font-semibold);
214
259
font-size: var(--text-base);
215
260
letter-spacing: 0.08em;
···
217
262
text-transform: uppercase;
218
263
}
219
264
265
+
.hostname.placeholder {
266
+
opacity: 0.4;
267
+
}
268
+
269
+
.user-count {
270
+
font-size: var(--text-sm);
271
+
color: rgba(255, 255, 255, 0.85);
272
+
padding: 4px 10px;
273
+
background: rgba(255, 255, 255, 0.15);
274
+
border-radius: var(--radius-md);
275
+
}
276
+
220
277
.nav-meta {
221
278
font-size: var(--text-sm);
222
279
color: rgba(255, 255, 255, 0.7);
···
319
376
320
377
.content h2 {
321
378
font-size: var(--text-sm);
322
-
font-weight: var(--font-semibold);
379
+
font-weight: var(--font-bold);
323
380
text-transform: uppercase;
324
381
letter-spacing: 0.1em;
325
-
color: var(--accent);
382
+
color: var(--accent-light);
326
383
margin: var(--space-8) 0 var(--space-5);
327
384
}
328
385
···
380
437
381
438
.btn {
382
439
text-align: center;
440
+
}
441
+
442
+
.nav-meta {
443
+
display: none;
383
444
}
384
445
}
385
446
+55
frontend/src/routes/Register.svelte
+55
frontend/src/routes/Register.svelte
···
132
132
</script>
133
133
134
134
<div class="register-page">
135
+
<div class="migrate-callout">
136
+
<div class="migrate-icon">↗</div>
137
+
<div class="migrate-content">
138
+
<strong>{$_('register.migrateTitle')}</strong>
139
+
<p>{$_('register.migrateDescription')}</p>
140
+
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
141
+
{$_('register.migrateLink')} →
142
+
</a>
143
+
</div>
144
+
</div>
145
+
135
146
{#if error}
136
147
<div class="message error">{error}</div>
137
148
{/if}
···
343
354
max-width: var(--width-sm);
344
355
margin: var(--space-9) auto;
345
356
padding: var(--space-7);
357
+
}
358
+
359
+
.migrate-callout {
360
+
display: flex;
361
+
gap: var(--space-4);
362
+
padding: var(--space-5);
363
+
background: var(--accent-muted);
364
+
border: 1px solid var(--accent);
365
+
border-radius: var(--radius-xl);
366
+
margin-bottom: var(--space-6);
367
+
}
368
+
369
+
.migrate-icon {
370
+
font-size: var(--text-2xl);
371
+
line-height: 1;
372
+
color: var(--accent);
373
+
}
374
+
375
+
.migrate-content {
376
+
flex: 1;
377
+
}
378
+
379
+
.migrate-content strong {
380
+
display: block;
381
+
color: var(--text-primary);
382
+
margin-bottom: var(--space-2);
383
+
}
384
+
385
+
.migrate-content p {
386
+
margin: 0 0 var(--space-3) 0;
387
+
font-size: var(--text-sm);
388
+
color: var(--text-secondary);
389
+
line-height: var(--leading-relaxed);
390
+
}
391
+
392
+
.migrate-link {
393
+
font-size: var(--text-sm);
394
+
font-weight: var(--font-medium);
395
+
color: var(--accent);
396
+
text-decoration: none;
397
+
}
398
+
399
+
.migrate-link:hover {
400
+
text-decoration: underline;
346
401
}
347
402
348
403
h1 {
+57
frontend/src/routes/RegisterPasskey.svelte
+57
frontend/src/routes/RegisterPasskey.svelte
···
303
303
</script>
304
304
305
305
<div class="register-page">
306
+
{#if step === 'info'}
307
+
<div class="migrate-callout">
308
+
<div class="migrate-icon">↗</div>
309
+
<div class="migrate-content">
310
+
<strong>{$_('register.migrateTitle')}</strong>
311
+
<p>{$_('register.migrateDescription')}</p>
312
+
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
313
+
{$_('register.migrateLink')} →
314
+
</a>
315
+
</div>
316
+
</div>
317
+
{/if}
318
+
306
319
<h1>Create Passkey Account</h1>
307
320
<p class="subtitle">
308
321
{#if step === 'info'}
···
539
552
max-width: var(--width-sm);
540
553
margin: var(--space-9) auto;
541
554
padding: var(--space-7);
555
+
}
556
+
557
+
.migrate-callout {
558
+
display: flex;
559
+
gap: var(--space-4);
560
+
padding: var(--space-5);
561
+
background: var(--accent-muted);
562
+
border: 1px solid var(--accent);
563
+
border-radius: var(--radius-xl);
564
+
margin-bottom: var(--space-6);
565
+
}
566
+
567
+
.migrate-icon {
568
+
font-size: var(--text-2xl);
569
+
line-height: 1;
570
+
color: var(--accent);
571
+
}
572
+
573
+
.migrate-content {
574
+
flex: 1;
575
+
}
576
+
577
+
.migrate-content strong {
578
+
display: block;
579
+
color: var(--text-primary);
580
+
margin-bottom: var(--space-2);
581
+
}
582
+
583
+
.migrate-content p {
584
+
margin: 0 0 var(--space-3) 0;
585
+
font-size: var(--text-sm);
586
+
color: var(--text-secondary);
587
+
line-height: var(--leading-relaxed);
588
+
}
589
+
590
+
.migrate-link {
591
+
font-size: var(--text-sm);
592
+
font-weight: var(--font-medium);
593
+
color: var(--accent);
594
+
text-decoration: none;
595
+
}
596
+
597
+
.migrate-link:hover {
598
+
text-decoration: underline;
542
599
}
543
600
544
601
h1, h2 {
+14
frontend/src/styles/base.css
+14
frontend/src/styles/base.css
···
1
1
@import './tokens.css';
2
2
3
+
@property --accent {
4
+
syntax: '<color>';
5
+
inherits: true;
6
+
initial-value: #2c00ff;
7
+
}
8
+
9
+
@property --secondary {
10
+
syntax: '<color>';
11
+
inherits: true;
12
+
initial-value: #ff2400;
13
+
}
14
+
3
15
*,
4
16
*::before,
5
17
*::after {
···
15
27
background: var(--bg-primary);
16
28
-webkit-font-smoothing: antialiased;
17
29
-moz-osx-font-smoothing: grayscale;
30
+
transition: background-color 0.3s ease;
18
31
}
19
32
20
33
h1, h2, h3, h4, h5, h6 {
···
34
47
a {
35
48
color: var(--secondary);
36
49
text-decoration: none;
50
+
transition: color 0.3s ease;
37
51
}
38
52
39
53
a:hover {
+7
-7
frontend/src/styles/tokens.css
+7
-7
frontend/src/styles/tokens.css
···
106
106
--border-light: #222222;
107
107
--border-dark: #333333;
108
108
109
-
--accent: #2c00ff;
110
-
--accent-hover: #4d33ff;
111
-
--accent-muted: rgba(44, 0, 255, 0.15);
112
-
--accent-light: #4d33ff;
109
+
--accent: #7b6bff;
110
+
--accent-hover: #9588ff;
111
+
--accent-muted: rgba(123, 107, 255, 0.2);
112
+
--accent-light: #9588ff;
113
113
114
-
--secondary: #ff2400;
115
-
--secondary-hover: #ff5533;
116
-
--secondary-muted: rgba(255, 36, 0, 0.15);
114
+
--secondary: #ff6b5b;
115
+
--secondary-hover: #ff8577;
116
+
--secondary-muted: rgba(255, 107, 91, 0.2);
117
117
118
118
--success-bg: #1a3d1a;
119
119
--success-border: #2d5a2d;
+7
migrations/20251231_server_config.sql
+7
migrations/20251231_server_config.sql
+194
src/api/admin/config.rs
+194
src/api/admin/config.rs
···
1
+
use crate::api::error::ApiError;
2
+
use crate::auth::BearerAuthAdmin;
3
+
use crate::state::AppState;
4
+
use axum::{extract::State, Json};
5
+
use serde::{Deserialize, Serialize};
6
+
use tracing::error;
7
+
8
+
#[derive(Serialize)]
9
+
#[serde(rename_all = "camelCase")]
10
+
pub struct ServerConfigResponse {
11
+
pub server_name: String,
12
+
pub primary_color: Option<String>,
13
+
pub primary_color_dark: Option<String>,
14
+
pub secondary_color: Option<String>,
15
+
pub secondary_color_dark: Option<String>,
16
+
pub logo_cid: Option<String>,
17
+
}
18
+
19
+
#[derive(Deserialize)]
20
+
#[serde(rename_all = "camelCase")]
21
+
pub struct UpdateServerConfigRequest {
22
+
pub server_name: Option<String>,
23
+
pub primary_color: Option<String>,
24
+
pub primary_color_dark: Option<String>,
25
+
pub secondary_color: Option<String>,
26
+
pub secondary_color_dark: Option<String>,
27
+
pub logo_cid: Option<String>,
28
+
}
29
+
30
+
#[derive(Serialize)]
31
+
pub struct UpdateServerConfigResponse {
32
+
pub success: bool,
33
+
}
34
+
35
+
fn is_valid_hex_color(s: &str) -> bool {
36
+
if s.len() != 7 || !s.starts_with('#') {
37
+
return false;
38
+
}
39
+
s[1..].chars().all(|c| c.is_ascii_hexdigit())
40
+
}
41
+
42
+
pub async fn get_server_config(
43
+
State(state): State<AppState>,
44
+
) -> Result<Json<ServerConfigResponse>, ApiError> {
45
+
let rows: Vec<(String, String)> = sqlx::query_as(
46
+
"SELECT key, value FROM server_config WHERE key IN ('server_name', 'primary_color', 'primary_color_dark', 'secondary_color', 'secondary_color_dark', 'logo_cid')"
47
+
)
48
+
.fetch_all(&state.db)
49
+
.await?;
50
+
51
+
let mut server_name = "Tranquil PDS".to_string();
52
+
let mut primary_color = None;
53
+
let mut primary_color_dark = None;
54
+
let mut secondary_color = None;
55
+
let mut secondary_color_dark = None;
56
+
let mut logo_cid = None;
57
+
58
+
for (key, value) in rows {
59
+
match key.as_str() {
60
+
"server_name" => server_name = value,
61
+
"primary_color" => primary_color = Some(value),
62
+
"primary_color_dark" => primary_color_dark = Some(value),
63
+
"secondary_color" => secondary_color = Some(value),
64
+
"secondary_color_dark" => secondary_color_dark = Some(value),
65
+
"logo_cid" => logo_cid = Some(value),
66
+
_ => {}
67
+
}
68
+
}
69
+
70
+
Ok(Json(ServerConfigResponse {
71
+
server_name,
72
+
primary_color,
73
+
primary_color_dark,
74
+
secondary_color,
75
+
secondary_color_dark,
76
+
logo_cid,
77
+
}))
78
+
}
79
+
80
+
async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> {
81
+
sqlx::query(
82
+
"INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW())
83
+
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()"
84
+
)
85
+
.bind(key)
86
+
.bind(value)
87
+
.execute(db)
88
+
.await?;
89
+
Ok(())
90
+
}
91
+
92
+
async fn delete_config(db: &sqlx::PgPool, key: &str) -> Result<(), sqlx::Error> {
93
+
sqlx::query("DELETE FROM server_config WHERE key = $1")
94
+
.bind(key)
95
+
.execute(db)
96
+
.await?;
97
+
Ok(())
98
+
}
99
+
100
+
pub async fn update_server_config(
101
+
State(state): State<AppState>,
102
+
_admin: BearerAuthAdmin,
103
+
Json(req): Json<UpdateServerConfigRequest>,
104
+
) -> Result<Json<UpdateServerConfigResponse>, ApiError> {
105
+
if let Some(server_name) = req.server_name {
106
+
let trimmed = server_name.trim();
107
+
if trimmed.is_empty() || trimmed.len() > 100 {
108
+
return Err(ApiError::InvalidRequest("Server name must be 1-100 characters".into()));
109
+
}
110
+
upsert_config(&state.db, "server_name", trimmed).await?;
111
+
}
112
+
113
+
if let Some(ref color) = req.primary_color {
114
+
if color.is_empty() {
115
+
delete_config(&state.db, "primary_color").await?;
116
+
} else if is_valid_hex_color(color) {
117
+
upsert_config(&state.db, "primary_color", color).await?;
118
+
} else {
119
+
return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into()));
120
+
}
121
+
}
122
+
123
+
if let Some(ref color) = req.primary_color_dark {
124
+
if color.is_empty() {
125
+
delete_config(&state.db, "primary_color_dark").await?;
126
+
} else if is_valid_hex_color(color) {
127
+
upsert_config(&state.db, "primary_color_dark", color).await?;
128
+
} else {
129
+
return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into()));
130
+
}
131
+
}
132
+
133
+
if let Some(ref color) = req.secondary_color {
134
+
if color.is_empty() {
135
+
delete_config(&state.db, "secondary_color").await?;
136
+
} else if is_valid_hex_color(color) {
137
+
upsert_config(&state.db, "secondary_color", color).await?;
138
+
} else {
139
+
return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into()));
140
+
}
141
+
}
142
+
143
+
if let Some(ref color) = req.secondary_color_dark {
144
+
if color.is_empty() {
145
+
delete_config(&state.db, "secondary_color_dark").await?;
146
+
} else if is_valid_hex_color(color) {
147
+
upsert_config(&state.db, "secondary_color_dark", color).await?;
148
+
} else {
149
+
return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into()));
150
+
}
151
+
}
152
+
153
+
if let Some(ref logo_cid) = req.logo_cid {
154
+
let old_logo_cid: Option<String> = sqlx::query_scalar(
155
+
"SELECT value FROM server_config WHERE key = 'logo_cid'"
156
+
)
157
+
.fetch_optional(&state.db)
158
+
.await?;
159
+
160
+
let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
161
+
(Some(old), true) => Some(old.clone()),
162
+
(Some(old), false) if old != logo_cid => Some(old.clone()),
163
+
_ => None,
164
+
};
165
+
166
+
if let Some(old_cid) = should_delete_old {
167
+
if let Ok(Some(blob)) = sqlx::query!(
168
+
"SELECT storage_key FROM blobs WHERE cid = $1",
169
+
old_cid
170
+
)
171
+
.fetch_optional(&state.db)
172
+
.await
173
+
{
174
+
if let Err(e) = state.blob_store.delete(&blob.storage_key).await {
175
+
error!("Failed to delete old logo blob from storage: {:?}", e);
176
+
}
177
+
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid)
178
+
.execute(&state.db)
179
+
.await
180
+
{
181
+
error!("Failed to delete old logo blob record: {:?}", e);
182
+
}
183
+
}
184
+
}
185
+
186
+
if logo_cid.is_empty() {
187
+
delete_config(&state.db, "logo_cid").await?;
188
+
} else {
189
+
upsert_config(&state.db, "logo_cid", logo_cid).await?;
190
+
}
191
+
}
192
+
193
+
Ok(Json(UpdateServerConfigResponse { success: true }))
194
+
}
+2
src/api/admin/mod.rs
+2
src/api/admin/mod.rs
···
1
1
pub mod account;
2
+
pub mod config;
2
3
pub mod invite;
3
4
pub mod server_stats;
4
5
pub mod status;
···
7
8
delete_account, get_account_info, get_account_infos, search_accounts, send_email,
8
9
update_account_email, update_account_handle, update_account_password,
9
10
};
11
+
pub use config::{get_server_config, update_server_config};
10
12
pub use invite::{
11
13
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
12
14
};
+57
src/api/server/logo.rs
+57
src/api/server/logo.rs
···
1
+
use crate::state::AppState;
2
+
use axum::{
3
+
body::Body,
4
+
extract::State,
5
+
http::StatusCode,
6
+
http::header,
7
+
response::{IntoResponse, Response},
8
+
};
9
+
use tracing::error;
10
+
11
+
pub async fn get_logo(State(state): State<AppState>) -> Response {
12
+
let logo_cid: Option<String> = match sqlx::query_scalar(
13
+
"SELECT value FROM server_config WHERE key = 'logo_cid'"
14
+
)
15
+
.fetch_optional(&state.db)
16
+
.await
17
+
{
18
+
Ok(cid) => cid,
19
+
Err(e) => {
20
+
error!("DB error fetching logo_cid: {:?}", e);
21
+
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
22
+
}
23
+
};
24
+
25
+
let cid = match logo_cid {
26
+
Some(c) if !c.is_empty() => c,
27
+
_ => return StatusCode::NOT_FOUND.into_response(),
28
+
};
29
+
30
+
let blob = match sqlx::query!(
31
+
"SELECT storage_key, mime_type FROM blobs WHERE cid = $1",
32
+
cid
33
+
)
34
+
.fetch_optional(&state.db)
35
+
.await
36
+
{
37
+
Ok(Some(row)) => row,
38
+
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
39
+
Err(e) => {
40
+
error!("DB error fetching blob: {:?}", e);
41
+
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
42
+
}
43
+
};
44
+
45
+
match state.blob_store.get(&blob.storage_key).await {
46
+
Ok(data) => Response::builder()
47
+
.status(StatusCode::OK)
48
+
.header(header::CONTENT_TYPE, &blob.mime_type)
49
+
.header(header::CACHE_CONTROL, "public, max-age=3600")
50
+
.body(Body::from(data))
51
+
.unwrap(),
52
+
Err(e) => {
53
+
error!("Failed to fetch logo from storage: {:?}", e);
54
+
StatusCode::NOT_FOUND.into_response()
55
+
}
56
+
}
57
+
}
+2
-1
src/api/server/meta.rs
+2
-1
src/api/server/meta.rs
···
20
20
Json(json!({
21
21
"availableUserDomains": domains,
22
22
"inviteCodeRequired": invite_code_required,
23
-
"did": format!("did:web:{}", pds_hostname)
23
+
"did": format!("did:web:{}", pds_hostname),
24
+
"version": env!("CARGO_PKG_VERSION")
24
25
}))
25
26
}
26
27
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
+2
src/api/server/mod.rs
+2
src/api/server/mod.rs
···
2
2
pub mod app_password;
3
3
pub mod email;
4
4
pub mod invite;
5
+
pub mod logo;
5
6
pub mod meta;
6
7
pub mod passkey_account;
7
8
pub mod passkeys;
···
20
21
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
21
22
pub use email::{confirm_email, request_email_update, update_email};
22
23
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
24
+
pub use logo::get_logo;
23
25
pub use meta::{describe_server, health, robots_txt};
24
26
pub use passkey_account::{
25
27
complete_passkey_setup, create_passkey_account, recover_passkey_account,
+9
src/lib.rs
+9
src/lib.rs
···
35
35
.route("/health", get(api::server::health))
36
36
.route("/xrpc/_health", get(api::server::health))
37
37
.route("/robots.txt", get(api::server::robots_txt))
38
+
.route("/logo", get(api::server::get_logo))
38
39
.route(
39
40
"/xrpc/com.atproto.server.describeServer",
40
41
get(api::server::describe_server),
···
401
402
.route(
402
403
"/xrpc/com.tranquil.admin.getServerStats",
403
404
get(api::admin::get_server_stats),
405
+
)
406
+
.route(
407
+
"/xrpc/com.tranquil.server.getConfig",
408
+
get(api::admin::get_server_config),
409
+
)
410
+
.route(
411
+
"/xrpc/com.tranquil.admin.updateServerConfig",
412
+
post(api::admin::update_server_config),
404
413
)
405
414
.route(
406
415
"/xrpc/com.atproto.admin.disableAccountInvites",
+1
-1
src/oauth/endpoints/metadata.rs
+1
-1
src/oauth/endpoints/metadata.rs
···
172
172
"refresh_token".to_string(),
173
173
],
174
174
response_types: vec!["code".to_string()],
175
-
scope: "atproto transition:generic".to_string(),
175
+
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*".to_string(),
176
176
token_endpoint_auth_method: "none".to_string(),
177
177
application_type: "web".to_string(),
178
178
dpop_bound_access_tokens: true,