mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-08 21:30:08 +00:00
Generic cosmetic per-server config
This commit is contained in:
22
.sqlx/query-6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052.json
generated
Normal file
22
.sqlx/query-6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT storage_key FROM blobs WHERE cid = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "storage_key",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6131bb5b39ca81bdbb193c0a9867bead8d9f3d793ad4eca97a79d166467a5052"
|
||||
}
|
||||
14
.sqlx/query-d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12.json
generated
Normal file
14
.sqlx/query-d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM blobs WHERE cid = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d2990ce7f233d2489bb36a63920571c9f454a0605cc463829693d581bc0dce12"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentPath, navigate } from './lib/router.svelte'
|
||||
import { initAuth, getAuthState } from './lib/auth.svelte'
|
||||
import { initServerConfig } from './lib/serverConfig.svelte'
|
||||
import { initI18n, _ } from './lib/i18n'
|
||||
import { isLoading as i18nLoading } from 'svelte-i18n'
|
||||
import Login from './routes/Login.svelte'
|
||||
@@ -41,6 +42,7 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
initServerConfig()
|
||||
initAuth().then(({ oauthLoginCompleted }) => {
|
||||
if (oauthLoginCompleted) {
|
||||
navigate('/dashboard')
|
||||
|
||||
@@ -265,10 +265,20 @@ export const api = {
|
||||
availableUserDomains: string[]
|
||||
inviteCodeRequired: boolean
|
||||
links?: { privacyPolicy?: string; termsOfService?: string }
|
||||
version?: string
|
||||
}> {
|
||||
return xrpc('com.atproto.server.describeServer')
|
||||
},
|
||||
|
||||
async listRepos(limit?: number): Promise<{
|
||||
repos: Array<{ did: string; head: string; rev: string }>
|
||||
cursor?: string
|
||||
}> {
|
||||
const params: Record<string, string> = {}
|
||||
if (limit) params.limit = String(limit)
|
||||
return xrpc('com.atproto.sync.listRepos', { params })
|
||||
},
|
||||
|
||||
async getNotificationPrefs(token: string): Promise<{
|
||||
preferredChannel: string
|
||||
email: string
|
||||
@@ -325,6 +335,51 @@ export const api = {
|
||||
return xrpc('com.tranquil.admin.getServerStats', { token })
|
||||
},
|
||||
|
||||
async getServerConfig(): Promise<{
|
||||
serverName: string
|
||||
primaryColor: string | null
|
||||
primaryColorDark: string | null
|
||||
secondaryColor: string | null
|
||||
secondaryColorDark: string | null
|
||||
logoCid: string | null
|
||||
}> {
|
||||
return xrpc('com.tranquil.server.getConfig')
|
||||
},
|
||||
|
||||
async updateServerConfig(
|
||||
token: string,
|
||||
config: {
|
||||
serverName?: string
|
||||
primaryColor?: string
|
||||
primaryColorDark?: string
|
||||
secondaryColor?: string
|
||||
secondaryColorDark?: string
|
||||
logoCid?: string
|
||||
}
|
||||
): Promise<{ success: boolean }> {
|
||||
return xrpc('com.tranquil.admin.updateServerConfig', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: config,
|
||||
})
|
||||
},
|
||||
|
||||
async uploadBlob(token: string, file: File): Promise<{ blob: { $type: string; ref: { $link: string }; mimeType: string; size: number } }> {
|
||||
const res = await fetch('/xrpc/com.atproto.repo.uploadBlob', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
body: file,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
|
||||
throw new ApiError(res.status, err.error, err.message)
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
|
||||
async changePassword(token: string, currentPassword: string, newPassword: string): Promise<void> {
|
||||
await xrpc('com.tranquil.account.changePassword', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -75,7 +75,13 @@ export async function startOAuthLogin(): Promise<void> {
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'atproto transition:generic',
|
||||
scope: [
|
||||
'atproto',
|
||||
'repo:*?action=create',
|
||||
'repo:*?action=update',
|
||||
'repo:*?action=delete',
|
||||
'blob:*/*',
|
||||
].join(' '),
|
||||
state: state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
|
||||
123
frontend/src/lib/serverConfig.svelte.ts
Normal file
123
frontend/src/lib/serverConfig.svelte.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { api } from './api'
|
||||
|
||||
interface ServerConfigState {
|
||||
serverName: string | null
|
||||
primaryColor: string | null
|
||||
primaryColorDark: string | null
|
||||
secondaryColor: string | null
|
||||
secondaryColorDark: string | null
|
||||
hasLogo: boolean
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
let state = $state<ServerConfigState>({
|
||||
serverName: null,
|
||||
primaryColor: null,
|
||||
primaryColorDark: null,
|
||||
secondaryColor: null,
|
||||
secondaryColorDark: null,
|
||||
hasLogo: false,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
let initialized = false
|
||||
let darkModeQuery: MediaQueryList | null = null
|
||||
|
||||
function isDarkMode(): boolean {
|
||||
return darkModeQuery?.matches ?? false
|
||||
}
|
||||
|
||||
function applyColors() {
|
||||
const root = document.documentElement
|
||||
const dark = isDarkMode()
|
||||
|
||||
if (dark) {
|
||||
if (state.primaryColorDark) {
|
||||
root.style.setProperty('--accent', state.primaryColorDark)
|
||||
} else {
|
||||
root.style.removeProperty('--accent')
|
||||
}
|
||||
if (state.secondaryColorDark) {
|
||||
root.style.setProperty('--secondary', state.secondaryColorDark)
|
||||
} else {
|
||||
root.style.removeProperty('--secondary')
|
||||
}
|
||||
} else {
|
||||
if (state.primaryColor) {
|
||||
root.style.setProperty('--accent', state.primaryColor)
|
||||
} else {
|
||||
root.style.removeProperty('--accent')
|
||||
}
|
||||
if (state.secondaryColor) {
|
||||
root.style.setProperty('--secondary', state.secondaryColor)
|
||||
} else {
|
||||
root.style.removeProperty('--secondary')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setFavicon(hasLogo: boolean) {
|
||||
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']")
|
||||
if (hasLogo) {
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.href = '/logo'
|
||||
} else if (link) {
|
||||
link.remove()
|
||||
}
|
||||
}
|
||||
|
||||
export async function initServerConfig(): Promise<void> {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
darkModeQuery.addEventListener('change', applyColors)
|
||||
|
||||
try {
|
||||
const config = await api.getServerConfig()
|
||||
state.serverName = config.serverName
|
||||
state.primaryColor = config.primaryColor
|
||||
state.primaryColorDark = config.primaryColorDark
|
||||
state.secondaryColor = config.secondaryColor
|
||||
state.secondaryColorDark = config.secondaryColorDark
|
||||
state.hasLogo = !!config.logoCid
|
||||
document.title = config.serverName
|
||||
applyColors()
|
||||
setFavicon(state.hasLogo)
|
||||
} catch {
|
||||
state.serverName = null
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export function getServerConfigState() {
|
||||
return state
|
||||
}
|
||||
|
||||
export function setServerName(name: string) {
|
||||
state.serverName = name
|
||||
document.title = name
|
||||
}
|
||||
|
||||
export function setColors(colors: {
|
||||
primaryColor?: string | null
|
||||
primaryColorDark?: string | null
|
||||
secondaryColor?: string | null
|
||||
secondaryColorDark?: string | null
|
||||
}) {
|
||||
if (colors.primaryColor !== undefined) state.primaryColor = colors.primaryColor
|
||||
if (colors.primaryColorDark !== undefined) state.primaryColorDark = colors.primaryColorDark
|
||||
if (colors.secondaryColor !== undefined) state.secondaryColor = colors.secondaryColor
|
||||
if (colors.secondaryColorDark !== undefined) state.secondaryColorDark = colors.secondaryColorDark
|
||||
applyColors()
|
||||
}
|
||||
|
||||
export function setHasLogo(hasLogo: boolean) {
|
||||
state.hasLogo = hasLogo
|
||||
setFavicon(hasLogo)
|
||||
}
|
||||
@@ -14,7 +14,9 @@
|
||||
"expires": "Expires",
|
||||
"name": "Name",
|
||||
"dashboard": "Dashboard",
|
||||
"backToDashboard": "← Dashboard"
|
||||
"backToDashboard": "← Dashboard",
|
||||
"copied": "Copied!",
|
||||
"copyToClipboard": "Copy to Clipboard"
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign In",
|
||||
@@ -45,6 +47,9 @@
|
||||
"register": {
|
||||
"title": "Create Account",
|
||||
"subtitle": "Create a new account on this PDS",
|
||||
"migrateTitle": "Already have a Bluesky account?",
|
||||
"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.",
|
||||
"migrateLink": "Migrate with PDS Moover",
|
||||
"handle": "Handle",
|
||||
"handlePlaceholder": "yourname",
|
||||
"handleHint": "Your full handle will be: @{handle}",
|
||||
@@ -226,7 +231,10 @@
|
||||
"revoke": "Revoke",
|
||||
"revoking": "Revoking...",
|
||||
"creating": "Creating...",
|
||||
"revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account."
|
||||
"revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.",
|
||||
"saveWarningTitle": "Important: Save this app password!",
|
||||
"saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.",
|
||||
"acknowledgeLabel": "I have saved my app password in a secure location"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Active Sessions",
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"expires": "Vanhenee",
|
||||
"name": "Nimi",
|
||||
"dashboard": "Hallintapaneeli",
|
||||
"backToDashboard": "← Hallintapaneeli"
|
||||
"backToDashboard": "← Hallintapaneeli",
|
||||
"copied": "Kopioitu!",
|
||||
"copyToClipboard": "Kopioi"
|
||||
},
|
||||
"login": {
|
||||
"title": "Kirjaudu sisään",
|
||||
@@ -45,6 +47,9 @@
|
||||
"register": {
|
||||
"title": "Luo tili",
|
||||
"subtitle": "Luo uusi tili tälle PDS:lle",
|
||||
"migrateTitle": "Onko sinulla jo Bluesky-tili?",
|
||||
"migrateDescription": "Voit siirtää olemassa olevan tilisi tälle PDS:lle uuden luomisen sijaan. Seuraajasi, julkaisusi ja identiteettisi siirtyvät mukana.",
|
||||
"migrateLink": "Siirrä PDS Mooverilla",
|
||||
"handle": "Käyttäjänimi",
|
||||
"handlePlaceholder": "nimesi",
|
||||
"handleHint": "Täydellinen käyttäjänimesi on: @{handle}",
|
||||
@@ -226,7 +231,10 @@
|
||||
"revoke": "Peruuta",
|
||||
"revoking": "Peruutetaan...",
|
||||
"creating": "Luodaan...",
|
||||
"revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi."
|
||||
"revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.",
|
||||
"saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!",
|
||||
"saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.",
|
||||
"acknowledgeLabel": "Olen tallentanut sovelluksen salasanani turvalliseen paikkaan"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Aktiiviset istunnot",
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"expires": "有効期限",
|
||||
"name": "名前",
|
||||
"dashboard": "ダッシュボード",
|
||||
"backToDashboard": "← ダッシュボード"
|
||||
"backToDashboard": "← ダッシュボード",
|
||||
"copied": "コピーしました!",
|
||||
"copyToClipboard": "クリップボードにコピー"
|
||||
},
|
||||
"login": {
|
||||
"title": "サインイン",
|
||||
@@ -45,6 +47,9 @@
|
||||
"register": {
|
||||
"title": "アカウント作成",
|
||||
"subtitle": "この PDS で新規アカウントを作成",
|
||||
"migrateTitle": "すでにBlueskyアカウントをお持ちですか?",
|
||||
"migrateDescription": "新しいアカウントを作成する代わりに、既存のアカウントをこのPDSに移行できます。フォロワー、投稿、IDも一緒に移行されます。",
|
||||
"migrateLink": "PDS Mooverで移行する",
|
||||
"handle": "ハンドル",
|
||||
"handlePlaceholder": "あなたの名前",
|
||||
"handleHint": "完全なハンドル: @{handle}",
|
||||
@@ -226,7 +231,10 @@
|
||||
"revoke": "取り消す",
|
||||
"revoking": "取り消し中...",
|
||||
"creating": "作成中...",
|
||||
"revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。"
|
||||
"revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。",
|
||||
"saveWarningTitle": "重要: このアプリパスワードを保存してください!",
|
||||
"saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。",
|
||||
"acknowledgeLabel": "アプリパスワードを安全な場所に保存しました"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "アクティブセッション",
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"expires": "만료일",
|
||||
"name": "이름",
|
||||
"dashboard": "대시보드",
|
||||
"backToDashboard": "← 대시보드"
|
||||
"backToDashboard": "← 대시보드",
|
||||
"copied": "복사됨!",
|
||||
"copyToClipboard": "클립보드에 복사"
|
||||
},
|
||||
"login": {
|
||||
"title": "로그인",
|
||||
@@ -45,6 +47,9 @@
|
||||
"register": {
|
||||
"title": "계정 만들기",
|
||||
"subtitle": "이 PDS에 새 계정을 만듭니다",
|
||||
"migrateTitle": "이미 Bluesky 계정이 있으신가요?",
|
||||
"migrateDescription": "새 계정을 만드는 대신 기존 계정을 이 PDS로 마이그레이션할 수 있습니다. 팔로워, 게시물, ID가 함께 이전됩니다.",
|
||||
"migrateLink": "PDS Moover로 마이그레이션",
|
||||
"handle": "핸들",
|
||||
"handlePlaceholder": "사용자 이름",
|
||||
"handleHint": "전체 핸들: @{handle}",
|
||||
@@ -226,7 +231,10 @@
|
||||
"revoke": "취소",
|
||||
"revoking": "취소 중...",
|
||||
"creating": "생성 중...",
|
||||
"revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다."
|
||||
"revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.",
|
||||
"saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!",
|
||||
"saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.",
|
||||
"acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "활성 세션",
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"expires": "Upphör",
|
||||
"name": "Namn",
|
||||
"dashboard": "Kontrollpanel",
|
||||
"backToDashboard": "← Kontrollpanel"
|
||||
"backToDashboard": "← Kontrollpanel",
|
||||
"copied": "Kopierat!",
|
||||
"copyToClipboard": "Kopiera"
|
||||
},
|
||||
"login": {
|
||||
"title": "Logga in",
|
||||
@@ -45,6 +47,9 @@
|
||||
"register": {
|
||||
"title": "Skapa konto",
|
||||
"subtitle": "Skapa ett nytt konto på denna PDS",
|
||||
"migrateTitle": "Har du redan ett Bluesky-konto?",
|
||||
"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.",
|
||||
"migrateLink": "Flytta med PDS Moover",
|
||||
"handle": "Användarnamn",
|
||||
"handlePlaceholder": "dittnamn",
|
||||
"handleHint": "Ditt fullständiga användarnamn blir: @{handle}",
|
||||
@@ -226,7 +231,10 @@
|
||||
"revoke": "Återkalla",
|
||||
"revoking": "Återkallar...",
|
||||
"creating": "Skapar...",
|
||||
"revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto."
|
||||
"revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.",
|
||||
"saveWarningTitle": "Viktigt: Spara detta applösenord!",
|
||||
"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.",
|
||||
"acknowledgeLabel": "Jag har sparat mitt applösenord på en säker plats"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Aktiva sessioner",
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"expires": "过期时间",
|
||||
"name": "名称",
|
||||
"dashboard": "控制台",
|
||||
"backToDashboard": "← 返回控制台"
|
||||
"backToDashboard": "← 返回控制台",
|
||||
"copied": "已复制!",
|
||||
"copyToClipboard": "复制"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
@@ -45,6 +47,9 @@
|
||||
"register": {
|
||||
"title": "创建账户",
|
||||
"subtitle": "在此 PDS 上创建新账户",
|
||||
"migrateTitle": "已有 Bluesky 账户?",
|
||||
"migrateDescription": "您可以将现有账户迁移到此 PDS,而无需创建新账户。您的关注者、帖子和身份都会一起迁移。",
|
||||
"migrateLink": "使用 PDS Moover 迁移",
|
||||
"handle": "用户名",
|
||||
"handlePlaceholder": "您的用户名",
|
||||
"handleHint": "您的完整用户名将是:@{handle}",
|
||||
@@ -226,7 +231,10 @@
|
||||
"revoke": "撤销",
|
||||
"revoking": "撤销中...",
|
||||
"creating": "创建中...",
|
||||
"revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。"
|
||||
"revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。",
|
||||
"saveWarningTitle": "重要:请保存此应用专用密码!",
|
||||
"saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。",
|
||||
"acknowledgeLabel": "我已将应用专用密码保存在安全的地方"
|
||||
},
|
||||
"sessions": {
|
||||
"title": "登录会话",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { setServerName as setGlobalServerName, setColors as setGlobalColors, setHasLogo as setGlobalHasLogo } from '../lib/serverConfig.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { _ } from '../lib/i18n'
|
||||
@@ -50,6 +51,23 @@
|
||||
} | null>(null)
|
||||
let userDetailLoading = $state(false)
|
||||
let userActionLoading = $state(false)
|
||||
let serverName = $state('')
|
||||
let serverNameInput = $state('')
|
||||
let primaryColor = $state('')
|
||||
let primaryColorInput = $state('')
|
||||
let primaryColorDark = $state('')
|
||||
let primaryColorDarkInput = $state('')
|
||||
let secondaryColor = $state('')
|
||||
let secondaryColorInput = $state('')
|
||||
let secondaryColorDark = $state('')
|
||||
let secondaryColorDarkInput = $state('')
|
||||
let logoCid = $state<string | null>(null)
|
||||
let originalLogoCid = $state<string | null>(null)
|
||||
let logoFile = $state<File | null>(null)
|
||||
let logoPreview = $state<string | null>(null)
|
||||
let serverConfigLoading = $state(false)
|
||||
let serverConfigError = $state<string | null>(null)
|
||||
let serverConfigSuccess = $state(false)
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
@@ -60,8 +78,100 @@
|
||||
$effect(() => {
|
||||
if (auth.session?.isAdmin) {
|
||||
loadStats()
|
||||
loadServerConfig()
|
||||
}
|
||||
})
|
||||
async function loadServerConfig() {
|
||||
try {
|
||||
const config = await api.getServerConfig()
|
||||
serverName = config.serverName
|
||||
serverNameInput = config.serverName
|
||||
primaryColor = config.primaryColor || ''
|
||||
primaryColorInput = config.primaryColor || ''
|
||||
primaryColorDark = config.primaryColorDark || ''
|
||||
primaryColorDarkInput = config.primaryColorDark || ''
|
||||
secondaryColor = config.secondaryColor || ''
|
||||
secondaryColorInput = config.secondaryColor || ''
|
||||
secondaryColorDark = config.secondaryColorDark || ''
|
||||
secondaryColorDarkInput = config.secondaryColorDark || ''
|
||||
logoCid = config.logoCid
|
||||
originalLogoCid = config.logoCid
|
||||
if (config.logoCid) {
|
||||
logoPreview = '/logo'
|
||||
}
|
||||
} catch (e) {
|
||||
serverConfigError = e instanceof ApiError ? e.message : 'Failed to load server config'
|
||||
}
|
||||
}
|
||||
async function saveServerConfig(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session) return
|
||||
serverConfigLoading = true
|
||||
serverConfigError = null
|
||||
serverConfigSuccess = false
|
||||
try {
|
||||
let newLogoCid = logoCid
|
||||
if (logoFile) {
|
||||
const result = await api.uploadBlob(auth.session.accessJwt, logoFile)
|
||||
newLogoCid = result.blob.ref.$link
|
||||
}
|
||||
await api.updateServerConfig(auth.session.accessJwt, {
|
||||
serverName: serverNameInput,
|
||||
primaryColor: primaryColorInput,
|
||||
primaryColorDark: primaryColorDarkInput,
|
||||
secondaryColor: secondaryColorInput,
|
||||
secondaryColorDark: secondaryColorDarkInput,
|
||||
logoCid: newLogoCid ?? '',
|
||||
})
|
||||
serverName = serverNameInput
|
||||
primaryColor = primaryColorInput
|
||||
primaryColorDark = primaryColorDarkInput
|
||||
secondaryColor = secondaryColorInput
|
||||
secondaryColorDark = secondaryColorDarkInput
|
||||
logoCid = newLogoCid
|
||||
originalLogoCid = newLogoCid
|
||||
logoFile = null
|
||||
setGlobalServerName(serverNameInput)
|
||||
setGlobalColors({
|
||||
primaryColor: primaryColorInput || null,
|
||||
primaryColorDark: primaryColorDarkInput || null,
|
||||
secondaryColor: secondaryColorInput || null,
|
||||
secondaryColorDark: secondaryColorDarkInput || null,
|
||||
})
|
||||
setGlobalHasLogo(!!newLogoCid)
|
||||
serverConfigSuccess = true
|
||||
setTimeout(() => { serverConfigSuccess = false }, 3000)
|
||||
} catch (e) {
|
||||
serverConfigError = e instanceof ApiError ? e.message : 'Failed to save server config'
|
||||
} finally {
|
||||
serverConfigLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogoChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
logoFile = file
|
||||
logoPreview = URL.createObjectURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
function removeLogo() {
|
||||
logoFile = null
|
||||
logoCid = null
|
||||
logoPreview = null
|
||||
}
|
||||
|
||||
function hasConfigChanges(): boolean {
|
||||
const logoChanged = logoFile !== null || logoCid !== originalLogoCid
|
||||
return serverNameInput !== serverName ||
|
||||
primaryColorInput !== primaryColor ||
|
||||
primaryColorDarkInput !== primaryColorDark ||
|
||||
secondaryColorInput !== secondaryColor ||
|
||||
secondaryColorDarkInput !== secondaryColorDark ||
|
||||
logoChanged
|
||||
}
|
||||
async function loadStats() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
@@ -201,6 +311,128 @@
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
<section>
|
||||
<h2>Server Configuration</h2>
|
||||
<form class="config-form" onsubmit={saveServerConfig}>
|
||||
<div class="form-group">
|
||||
<label for="serverName">Server Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverName"
|
||||
bind:value={serverNameInput}
|
||||
placeholder="My PDS"
|
||||
maxlength="100"
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
<span class="help-text">Displayed in the browser tab and other places</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="serverLogo">Server Logo</label>
|
||||
<div class="logo-upload">
|
||||
{#if logoPreview}
|
||||
<div class="logo-preview">
|
||||
<img src={logoPreview} alt="Logo preview" />
|
||||
<button type="button" class="remove-logo" onclick={removeLogo} disabled={serverConfigLoading}>Remove</button>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
type="file"
|
||||
id="serverLogo"
|
||||
accept="image/*"
|
||||
onchange={handleLogoChange}
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="help-text">Used as favicon and shown in the navbar</span>
|
||||
</div>
|
||||
|
||||
<h3 class="subsection-title">Theme Colors</h3>
|
||||
<p class="theme-hint">Leave blank to use default colors.</p>
|
||||
|
||||
<div class="color-grid">
|
||||
<div class="color-group">
|
||||
<label for="primaryColor">Primary (Light Mode)</label>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
bind:value={primaryColorInput}
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="primaryColor"
|
||||
bind:value={primaryColorInput}
|
||||
placeholder="#2c00ff (default)"
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-group">
|
||||
<label for="primaryColorDark">Primary (Dark Mode)</label>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
bind:value={primaryColorDarkInput}
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="primaryColorDark"
|
||||
bind:value={primaryColorDarkInput}
|
||||
placeholder="#7b6bff (default)"
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-group">
|
||||
<label for="secondaryColor">Secondary (Light Mode)</label>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
bind:value={secondaryColorInput}
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="secondaryColor"
|
||||
bind:value={secondaryColorInput}
|
||||
placeholder="#ff2400 (default)"
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-group">
|
||||
<label for="secondaryColorDark">Secondary (Dark Mode)</label>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
bind:value={secondaryColorDarkInput}
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="secondaryColorDark"
|
||||
bind:value={secondaryColorDarkInput}
|
||||
placeholder="#ff6b5b (default)"
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if serverConfigError}
|
||||
<div class="message error">{serverConfigError}</div>
|
||||
{/if}
|
||||
{#if serverConfigSuccess}
|
||||
<div class="message success">Server configuration saved</div>
|
||||
{/if}
|
||||
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
|
||||
{serverConfigLoading ? 'Saving...' : 'Save Configuration'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
{#if stats}
|
||||
<section>
|
||||
<h2>Server Statistics</h2>
|
||||
@@ -455,6 +687,139 @@
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.config-form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: var(--font-medium);
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.config-form button {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.config-form button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.config-form button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: var(--space-5) 0 var(--space-2) 0;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.theme-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.color-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.color-group label {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.color-group input[type="text"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo-upload {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.logo-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.logo-preview img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.remove-logo {
|
||||
background: transparent;
|
||||
color: var(--error-text);
|
||||
border: 1px solid var(--error-border);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.remove-logo:hover:not(:disabled) {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--bg-secondary);
|
||||
padding: var(--space-6);
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
let newPasswordName = $state('')
|
||||
let creating = $state(false)
|
||||
let createdPassword = $state<{ name: string; password: string } | null>(null)
|
||||
let passwordCopied = $state(false)
|
||||
let passwordAcknowledged = $state(false)
|
||||
let revoking = $state<string | null>(null)
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
@@ -67,8 +69,16 @@
|
||||
revoking = null
|
||||
}
|
||||
}
|
||||
function copyPassword() {
|
||||
if (createdPassword) {
|
||||
navigator.clipboard.writeText(createdPassword.password)
|
||||
passwordCopied = true
|
||||
}
|
||||
}
|
||||
function dismissCreated() {
|
||||
createdPassword = null
|
||||
passwordCopied = false
|
||||
passwordAcknowledged = false
|
||||
}
|
||||
</script>
|
||||
<div class="page">
|
||||
@@ -84,13 +94,22 @@
|
||||
{/if}
|
||||
{#if createdPassword}
|
||||
<div class="created-password">
|
||||
<h3>{$_('appPasswords.created')}</h3>
|
||||
<p>{$_('appPasswords.createdMessage')}</p>
|
||||
<div class="password-display">
|
||||
<code>{createdPassword.password}</code>
|
||||
<div class="warning-box">
|
||||
<strong>{$_('appPasswords.saveWarningTitle')}</strong>
|
||||
<p>{$_('appPasswords.saveWarningMessage')}</p>
|
||||
</div>
|
||||
<p class="password-name">{$_('common.name')}: {createdPassword.name}</p>
|
||||
<button onclick={dismissCreated}>{$_('common.done')}</button>
|
||||
<div class="password-display">
|
||||
<div class="password-label">{$_('common.name')}: <strong>{createdPassword.name}</strong></div>
|
||||
<code class="password-code">{createdPassword.password}</code>
|
||||
<button type="button" class="copy-btn" onclick={copyPassword}>
|
||||
{passwordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
|
||||
</button>
|
||||
</div>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={passwordAcknowledged} />
|
||||
<span>{$_('appPasswords.acknowledgeLabel')}</span>
|
||||
</label>
|
||||
<button onclick={dismissCreated} disabled={!passwordAcknowledged}>{$_('common.done')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
<section class="create-section">
|
||||
@@ -175,35 +194,78 @@
|
||||
}
|
||||
|
||||
.created-password {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6);
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.created-password h3 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
color: var(--success-text);
|
||||
.warning-box {
|
||||
padding: var(--space-5);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.warning-box strong {
|
||||
display: block;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
margin: 0;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.password-display {
|
||||
background: var(--bg-card);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
margin: var(--space-4) 0;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.password-display code {
|
||||
.password-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.password-code {
|
||||
display: block;
|
||||
font-size: var(--text-xl);
|
||||
font-family: ui-monospace, monospace;
|
||||
letter-spacing: 0.1em;
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-input);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
user-select: all;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.password-name {
|
||||
color: var(--text-secondary);
|
||||
.copy-btn {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-normal);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
|
||||
@@ -2,11 +2,31 @@
|
||||
import { onMount } from 'svelte'
|
||||
import { _ } from '../lib/i18n'
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { getServerConfigState } from '../lib/serverConfig.svelte'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
const auth = getAuthState()
|
||||
const serverConfig = getServerConfigState()
|
||||
const sourceUrl = 'https://tangled.org/lewis.moe/bspds-sandbox'
|
||||
|
||||
let pdsHostname = $state<string | null>(null)
|
||||
let pdsVersion = $state<string | null>(null)
|
||||
let userCount = $state<number | null>(null)
|
||||
|
||||
onMount(() => {
|
||||
api.describeServer().then(info => {
|
||||
if (info.availableUserDomains?.length) {
|
||||
pdsHostname = info.availableUserDomains[0]
|
||||
}
|
||||
if (info.version) {
|
||||
pdsVersion = info.version
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
api.listRepos(1000).then(data => {
|
||||
userCount = data.repos.length
|
||||
}).catch(() => {})
|
||||
|
||||
const pattern = document.getElementById('dotPattern')
|
||||
if (!pattern) return
|
||||
|
||||
@@ -65,8 +85,20 @@
|
||||
<div class="pattern-fade"></div>
|
||||
|
||||
<nav>
|
||||
<span class="brand">Tranquil PDS</span>
|
||||
<span class="nav-meta">0.1.0</span>
|
||||
<div class="nav-left">
|
||||
{#if serverConfig.hasLogo}
|
||||
<img src="/logo" alt="Logo" class="nav-logo" />
|
||||
{/if}
|
||||
{#if pdsHostname}
|
||||
<span class="hostname">{pdsHostname}</span>
|
||||
{#if userCount !== null}
|
||||
<span class="user-count">{userCount} {userCount === 1 ? 'user' : 'users'}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="hostname placeholder">loading...</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="nav-meta">{pdsVersion || ''}</span>
|
||||
</nav>
|
||||
|
||||
<div class="home">
|
||||
@@ -139,7 +171,7 @@
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>Open Source</span>
|
||||
<span>Made with care</span>
|
||||
<span>Made with patience</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -209,7 +241,20 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.hostname {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--text-base);
|
||||
letter-spacing: 0.08em;
|
||||
@@ -217,6 +262,18 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hostname.placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.user-count {
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.nav-meta {
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
@@ -319,10 +376,10 @@
|
||||
|
||||
.content h2 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
font-weight: var(--font-bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent);
|
||||
color: var(--accent-light);
|
||||
margin: var(--space-8) 0 var(--space-5);
|
||||
}
|
||||
|
||||
@@ -381,6 +438,10 @@
|
||||
.btn {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
|
||||
@@ -132,6 +132,17 @@
|
||||
</script>
|
||||
|
||||
<div class="register-page">
|
||||
<div class="migrate-callout">
|
||||
<div class="migrate-icon">↗</div>
|
||||
<div class="migrate-content">
|
||||
<strong>{$_('register.migrateTitle')}</strong>
|
||||
<p>{$_('register.migrateDescription')}</p>
|
||||
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
|
||||
{$_('register.migrateLink')} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
@@ -345,6 +356,50 @@
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.migrate-callout {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
background: var(--accent-muted);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.migrate-icon {
|
||||
font-size: var(--text-2xl);
|
||||
line-height: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.migrate-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.migrate-content strong {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.migrate-content p {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.migrate-link {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.migrate-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
|
||||
@@ -303,6 +303,19 @@
|
||||
</script>
|
||||
|
||||
<div class="register-page">
|
||||
{#if step === 'info'}
|
||||
<div class="migrate-callout">
|
||||
<div class="migrate-icon">↗</div>
|
||||
<div class="migrate-content">
|
||||
<strong>{$_('register.migrateTitle')}</strong>
|
||||
<p>{$_('register.migrateDescription')}</p>
|
||||
<a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link">
|
||||
{$_('register.migrateLink')} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h1>Create Passkey Account</h1>
|
||||
<p class="subtitle">
|
||||
{#if step === 'info'}
|
||||
@@ -541,6 +554,50 @@
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.migrate-callout {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
background: var(--accent-muted);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.migrate-icon {
|
||||
font-size: var(--text-2xl);
|
||||
line-height: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.migrate-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.migrate-content strong {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.migrate-content p {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.migrate-link {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.migrate-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
@import './tokens.css';
|
||||
|
||||
@property --accent {
|
||||
syntax: '<color>';
|
||||
inherits: true;
|
||||
initial-value: #2c00ff;
|
||||
}
|
||||
|
||||
@property --secondary {
|
||||
syntax: '<color>';
|
||||
inherits: true;
|
||||
initial-value: #ff2400;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -15,6 +27,7 @@ body {
|
||||
background: var(--bg-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@@ -34,6 +47,7 @@ p {
|
||||
a {
|
||||
color: var(--secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
|
||||
@@ -106,14 +106,14 @@
|
||||
--border-light: #222222;
|
||||
--border-dark: #333333;
|
||||
|
||||
--accent: #2c00ff;
|
||||
--accent-hover: #4d33ff;
|
||||
--accent-muted: rgba(44, 0, 255, 0.15);
|
||||
--accent-light: #4d33ff;
|
||||
--accent: #7b6bff;
|
||||
--accent-hover: #9588ff;
|
||||
--accent-muted: rgba(123, 107, 255, 0.2);
|
||||
--accent-light: #9588ff;
|
||||
|
||||
--secondary: #ff2400;
|
||||
--secondary-hover: #ff5533;
|
||||
--secondary-muted: rgba(255, 36, 0, 0.15);
|
||||
--secondary: #ff6b5b;
|
||||
--secondary-hover: #ff8577;
|
||||
--secondary-muted: rgba(255, 107, 91, 0.2);
|
||||
|
||||
--success-bg: #1a3d1a;
|
||||
--success-border: #2d5a2d;
|
||||
|
||||
7
migrations/20251231_server_config.sql
Normal file
7
migrations/20251231_server_config.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS server_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO server_config (key, value) VALUES ('server_name', 'Tranquil PDS') ON CONFLICT DO NOTHING;
|
||||
194
src/api/admin/config.rs
Normal file
194
src/api/admin/config.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use crate::api::error::ApiError;
|
||||
use crate::auth::BearerAuthAdmin;
|
||||
use crate::state::AppState;
|
||||
use axum::{extract::State, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerConfigResponse {
|
||||
pub server_name: String,
|
||||
pub primary_color: Option<String>,
|
||||
pub primary_color_dark: Option<String>,
|
||||
pub secondary_color: Option<String>,
|
||||
pub secondary_color_dark: Option<String>,
|
||||
pub logo_cid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateServerConfigRequest {
|
||||
pub server_name: Option<String>,
|
||||
pub primary_color: Option<String>,
|
||||
pub primary_color_dark: Option<String>,
|
||||
pub secondary_color: Option<String>,
|
||||
pub secondary_color_dark: Option<String>,
|
||||
pub logo_cid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UpdateServerConfigResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
fn is_valid_hex_color(s: &str) -> bool {
|
||||
if s.len() != 7 || !s.starts_with('#') {
|
||||
return false;
|
||||
}
|
||||
s[1..].chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
pub async fn get_server_config(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<ServerConfigResponse>, ApiError> {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT key, value FROM server_config WHERE key IN ('server_name', 'primary_color', 'primary_color_dark', 'secondary_color', 'secondary_color_dark', 'logo_cid')"
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let mut server_name = "Tranquil PDS".to_string();
|
||||
let mut primary_color = None;
|
||||
let mut primary_color_dark = None;
|
||||
let mut secondary_color = None;
|
||||
let mut secondary_color_dark = None;
|
||||
let mut logo_cid = None;
|
||||
|
||||
for (key, value) in rows {
|
||||
match key.as_str() {
|
||||
"server_name" => server_name = value,
|
||||
"primary_color" => primary_color = Some(value),
|
||||
"primary_color_dark" => primary_color_dark = Some(value),
|
||||
"secondary_color" => secondary_color = Some(value),
|
||||
"secondary_color_dark" => secondary_color_dark = Some(value),
|
||||
"logo_cid" => logo_cid = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(ServerConfigResponse {
|
||||
server_name,
|
||||
primary_color,
|
||||
primary_color_dark,
|
||||
secondary_color,
|
||||
secondary_color_dark,
|
||||
logo_cid,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn upsert_config(db: &sqlx::PgPool, key: &str, value: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO server_config (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()"
|
||||
)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_config(db: &sqlx::PgPool, key: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("DELETE FROM server_config WHERE key = $1")
|
||||
.bind(key)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_server_config(
|
||||
State(state): State<AppState>,
|
||||
_admin: BearerAuthAdmin,
|
||||
Json(req): Json<UpdateServerConfigRequest>,
|
||||
) -> Result<Json<UpdateServerConfigResponse>, ApiError> {
|
||||
if let Some(server_name) = req.server_name {
|
||||
let trimmed = server_name.trim();
|
||||
if trimmed.is_empty() || trimmed.len() > 100 {
|
||||
return Err(ApiError::InvalidRequest("Server name must be 1-100 characters".into()));
|
||||
}
|
||||
upsert_config(&state.db, "server_name", trimmed).await?;
|
||||
}
|
||||
|
||||
if let Some(ref color) = req.primary_color {
|
||||
if color.is_empty() {
|
||||
delete_config(&state.db, "primary_color").await?;
|
||||
} else if is_valid_hex_color(color) {
|
||||
upsert_config(&state.db, "primary_color", color).await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest("Invalid primary color format (expected #RRGGBB)".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref color) = req.primary_color_dark {
|
||||
if color.is_empty() {
|
||||
delete_config(&state.db, "primary_color_dark").await?;
|
||||
} else if is_valid_hex_color(color) {
|
||||
upsert_config(&state.db, "primary_color_dark", color).await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest("Invalid primary dark color format (expected #RRGGBB)".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref color) = req.secondary_color {
|
||||
if color.is_empty() {
|
||||
delete_config(&state.db, "secondary_color").await?;
|
||||
} else if is_valid_hex_color(color) {
|
||||
upsert_config(&state.db, "secondary_color", color).await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest("Invalid secondary color format (expected #RRGGBB)".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref color) = req.secondary_color_dark {
|
||||
if color.is_empty() {
|
||||
delete_config(&state.db, "secondary_color_dark").await?;
|
||||
} else if is_valid_hex_color(color) {
|
||||
upsert_config(&state.db, "secondary_color_dark", color).await?;
|
||||
} else {
|
||||
return Err(ApiError::InvalidRequest("Invalid secondary dark color format (expected #RRGGBB)".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref logo_cid) = req.logo_cid {
|
||||
let old_logo_cid: Option<String> = sqlx::query_scalar(
|
||||
"SELECT value FROM server_config WHERE key = 'logo_cid'"
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let should_delete_old = match (&old_logo_cid, logo_cid.is_empty()) {
|
||||
(Some(old), true) => Some(old.clone()),
|
||||
(Some(old), false) if old != logo_cid => Some(old.clone()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(old_cid) = should_delete_old {
|
||||
if let Ok(Some(blob)) = sqlx::query!(
|
||||
"SELECT storage_key FROM blobs WHERE cid = $1",
|
||||
old_cid
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = state.blob_store.delete(&blob.storage_key).await {
|
||||
error!("Failed to delete old logo blob from storage: {:?}", e);
|
||||
}
|
||||
if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE cid = $1", old_cid)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete old logo blob record: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if logo_cid.is_empty() {
|
||||
delete_config(&state.db, "logo_cid").await?;
|
||||
} else {
|
||||
upsert_config(&state.db, "logo_cid", logo_cid).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(UpdateServerConfigResponse { success: true }))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod account;
|
||||
pub mod config;
|
||||
pub mod invite;
|
||||
pub mod server_stats;
|
||||
pub mod status;
|
||||
@@ -7,6 +8,7 @@ pub use account::{
|
||||
delete_account, get_account_info, get_account_infos, search_accounts, send_email,
|
||||
update_account_email, update_account_handle, update_account_password,
|
||||
};
|
||||
pub use config::{get_server_config, update_server_config};
|
||||
pub use invite::{
|
||||
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
|
||||
};
|
||||
|
||||
57
src/api/server/logo.rs
Normal file
57
src/api/server/logo.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
http::header,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use tracing::error;
|
||||
|
||||
pub async fn get_logo(State(state): State<AppState>) -> Response {
|
||||
let logo_cid: Option<String> = match sqlx::query_scalar(
|
||||
"SELECT value FROM server_config WHERE key = 'logo_cid'"
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(cid) => cid,
|
||||
Err(e) => {
|
||||
error!("DB error fetching logo_cid: {:?}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let cid = match logo_cid {
|
||||
Some(c) if !c.is_empty() => c,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let blob = match sqlx::query!(
|
||||
"SELECT storage_key, mime_type FROM blobs WHERE cid = $1",
|
||||
cid
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error fetching blob: {:?}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match state.blob_store.get(&blob.storage_key).await {
|
||||
Ok(data) => Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, &blob.mime_type)
|
||||
.header(header::CACHE_CONTROL, "public, max-age=3600")
|
||||
.body(Body::from(data))
|
||||
.unwrap(),
|
||||
Err(e) => {
|
||||
error!("Failed to fetch logo from storage: {:?}", e);
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ pub async fn describe_server() -> impl IntoResponse {
|
||||
Json(json!({
|
||||
"availableUserDomains": domains,
|
||||
"inviteCodeRequired": invite_code_required,
|
||||
"did": format!("did:web:{}", pds_hostname)
|
||||
"did": format!("did:web:{}", pds_hostname),
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}))
|
||||
}
|
||||
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod account_status;
|
||||
pub mod app_password;
|
||||
pub mod email;
|
||||
pub mod invite;
|
||||
pub mod logo;
|
||||
pub mod meta;
|
||||
pub mod passkey_account;
|
||||
pub mod passkeys;
|
||||
@@ -20,6 +21,7 @@ pub use account_status::{
|
||||
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
|
||||
pub use email::{confirm_email, request_email_update, update_email};
|
||||
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
|
||||
pub use logo::get_logo;
|
||||
pub use meta::{describe_server, health, robots_txt};
|
||||
pub use passkey_account::{
|
||||
complete_passkey_setup, create_passkey_account, recover_passkey_account,
|
||||
|
||||
@@ -35,6 +35,7 @@ pub fn app(state: AppState) -> Router {
|
||||
.route("/health", get(api::server::health))
|
||||
.route("/xrpc/_health", get(api::server::health))
|
||||
.route("/robots.txt", get(api::server::robots_txt))
|
||||
.route("/logo", get(api::server::get_logo))
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.describeServer",
|
||||
get(api::server::describe_server),
|
||||
@@ -402,6 +403,14 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.tranquil.admin.getServerStats",
|
||||
get(api::admin::get_server_stats),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.server.getConfig",
|
||||
get(api::admin::get_server_config),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.admin.updateServerConfig",
|
||||
post(api::admin::update_server_config),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.admin.disableAccountInvites",
|
||||
post(api::admin::disable_account_invites),
|
||||
|
||||
@@ -172,7 +172,7 @@ pub async fn frontend_client_metadata(
|
||||
"refresh_token".to_string(),
|
||||
],
|
||||
response_types: vec!["code".to_string()],
|
||||
scope: "atproto transition:generic".to_string(),
|
||||
scope: "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*".to_string(),
|
||||
token_endpoint_auth_method: "none".to_string(),
|
||||
application_type: "web".to_string(),
|
||||
dpop_bound_access_tokens: true,
|
||||
|
||||
Reference in New Issue
Block a user