chore: stage changes before history rewrite

This commit is contained in:
2026-07-07 11:28:18 +08:00
parent a619b919be
commit 137304090a
13 changed files with 670 additions and 83 deletions

141
app/templates/admin.html Normal file
View File

@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block title %}{{ 'admin.title'|t(user.language) }} - {{ 'brand'|t(user.language) }}{% endblock %}
{% block content %}
<div class="section-header">
<h1>{{ 'admin.title'|t(user.language) }}</h1>
</div>
<div class="tabs" id="tabs">
<button class="tab {% if active_tab != 'config' %}active{% endif %}" onclick="switchTab('invites')">{{ 'admin.tab_invites'|t(user.language) }}</button>
<button class="tab {% if active_tab == 'config' %}active{% endif %}" onclick="switchTab('config')">{{ 'admin.tab_config'|t(user.language) }}</button>
</div>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<!-- ═══ Tab: Invites ═══ -->
<div id="tab-invites" class="tab-panel" {% if active_tab == 'config' %}style="display:none"{% endif %}>
<div class="form-card" style="margin-bottom:1.5rem">
<h3 style="margin-bottom:.75rem">{{ 'invites.create'|t(user.language) }}</h3>
<form method="post" action="/admin/invites/add" style="display:flex;flex-direction:column;gap:.85rem">
<div>
<label style="margin-top:0">{{ 'invites.code_label'|t(user.language) }}</label>
<input type="text" name="code" maxlength="8" placeholder="{{ 'invites.code_ph'|t(user.language) }}" style="text-transform:uppercase">
</div>
<div>
<label style="margin-top:0">{{ 'invites.limit_type'|t(user.language) }}</label>
<select name="limit_type" id="limit_type" onchange="toggleLimitFields()">
<option value="unlimited">{{ 'invites.unlimited'|t(user.language) }}</option>
<option value="uses">{{ 'invites.uses'|t(user.language) }}</option>
<option value="expires">{{ 'invites.expires'|t(user.language) }}</option>
</select>
</div>
<div id="max_uses_field" style="display:none;align-items:center;gap:.3rem">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.times'|t(user.language) }}</label>
<input type="number" name="max_uses" min="1" value="1" class="aif-input aif-short">
</div>
<div id="expires_field" style="display:none;align-items:center;gap:.3rem">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.expires'|t(user.language) }}</label>
<input type="datetime-local" name="expires_at" class="aif-input aif-date">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'invites.create_btn'|t(user.language) }}</button>
</div>
</form>
</div>
{% if codes %}
<div class="postcard-list">
{% for c in codes %}
<div class="postcard-row" style="flex-direction:column;align-items:stretch;gap:.75rem">
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
<strong style="font-family:monospace;font-size:1.1rem">{{ c.code }}</strong>
<span style="font-size:.8rem;color:var(--text-muted)">{{ 'invites.creator'|t(user.language) }}: {{ c.creator.username if c.creator else '-' }}</span>
<span style="font-size:.8rem;color:var(--text-muted)">{{ c.created_at|to_local|strftime('%Y-%m-%d %H:%M') }}</span>
{% if c.max_uses is not none %}
<span class="badge">{{ 'invites.used'|t(user.language) }} {{ c.use_count }} / {{ c.max_uses }}</span>
{% else %}
<span class="badge">{{ 'invites.used'|t(user.language) }} {{ c.use_count }} / ∞</span>
{% endif %}
{% if c.expires_at %}
<span class="badge">{{ 'invites.expires_at'|t(user.language) }}: {{ c.expires_at|to_local|strftime('%Y-%m-%d %H:%M') }}</span>
{% else %}
<span class="badge">{{ 'invites.no_expire'|t(user.language) }}</span>
{% endif %}
</div>
{% if c.registered_users %}
<div style="font-size:.8rem;color:var(--text-muted)">{{ 'invites.registered_users'|t(user.language) }}: {{ c.registered_users|map(attribute='username')|join(', ') }}</div>
{% endif %}
<form method="post" action="/admin/invites/{{ c.id }}/edit" class="admin-invite-form" id="edit-form-{{ c.id }}">
<div class="aif-field">
<select name="limit_type" class="aif-select" onchange="toggleEditFields({{ c.id }}, this.value)">
<option value="unlimited"{% if c.max_uses is none and not c.expires_at %} selected{% endif %}>{{ 'invites.unlimited'|t(user.language) }}</option>
<option value="uses"{% if c.max_uses is not none %} selected{% endif %}>{{ 'invites.uses'|t(user.language) }}</option>
<option value="expires"{% if c.expires_at %} selected{% endif %}>{{ 'invites.expires'|t(user.language) }}</option>
</select>
</div>
<div style="display:{% if c.max_uses is not none %}inline-flex{% else %}none{% endif %};align-items:center;gap:.3rem;align-self:flex-end">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.times'|t(user.language) }}</label>
<input type="number" name="max_uses" value="{{ c.max_uses or 1 }}" min="1" class="aif-input aif-short">
</div>
<div style="display:{% if c.expires_at %}inline-flex{% else %}none{% endif %};align-items:center;gap:.3rem;align-self:flex-end">
<label style="margin:0;font-size:.8rem;white-space:nowrap">{{ 'invites.expires'|t(user.language) }}</label>
<input type="datetime-local" name="expires_at" value="{{ c.expires_at|to_local|strftime('%Y-%m-%dT%H:%M') if c.expires_at else '' }}" class="aif-input aif-date">
</div>
<div style="display:flex;gap:.4rem;align-self:flex-end">
<button type="submit" class="btn btn-sm">{{ 'invites.save'|t(user.language) }}</button>
<button type="button" class="btn btn-danger btn-sm" onclick="if(confirm('{{ 'invites.confirm_delete'|t(user.language) }}'))this.closest('form').nextElementSibling.submit()">{{ 'invites.delete'|t(user.language) }}</button>
</div>
</form>
<form method="post" action="/admin/invites/{{ c.id }}/delete" style="display:none"></form>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🔑</div>
<p>{{ 'invites.empty'|t(user.language) }}</p>
</div>
{% endif %}
</div>
<!-- ═══ Tab: System Config ═══ -->
<div id="tab-config" class="tab-panel" {% if active_tab != 'config' %}style="display:none"{% endif %}>
<div class="form-card">
<h3 style="margin-bottom:.75rem">{{ 'admin.config_title'|t(user.language) }}</h3>
<form method="post" action="/admin/config">
<label>{{ 'admin.copyright_label'|t(user.language) }}</label>
<textarea name="copyright_text" rows="3" style="width:100%;resize:vertical">{{ config.get('copyright_text', '') }}</textarea>
<p style="font-size:.8rem;color:var(--text-muted);margin-top:.25rem">{{ 'admin.copyright_hint'|t(user.language) }}</p>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'admin.save_config'|t(user.language) }}</button>
</div>
</form>
</div>
</div>
<script>
function switchTab(name) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
event.currentTarget.classList.add('active');
document.getElementById('tab-' + name).style.display = '';
}
function toggleLimitFields() {
var t = document.getElementById('limit_type').value;
document.getElementById('max_uses_field').style.display = t === 'uses' ? 'inline-flex' : 'none';
document.getElementById('expires_field').style.display = t === 'expires' ? 'inline-flex' : 'none';
}
function toggleEditFields(id, val) {
var form = document.getElementById('edit-form-' + id);
var num = form.querySelector('[name="max_uses"]').parentElement;
var date = form.querySelector('[name="expires_at"]').parentElement;
num.style.display = val === 'uses' ? 'inline-flex' : 'none';
date.style.display = val === 'expires' ? 'inline-flex' : 'none';
}
</script>
{% endblock %}

View File

@@ -52,6 +52,21 @@
</div>
</div>
<div class="doc-section">
<h2>{{ 'api_docs.permissions'|t(user.language) }}</h2>
<p class="doc-desc">{{ 'api_docs.permissions_desc'|t(user.language) }}</p>
<table class="doc-table">
<tr><th>{{ 'api_docs.scope'|t(user.language) }}</th><th>{{ 'api_docs.scope_desc'|t(user.language) }}</th><th>{{ 'api_docs.scope_endpoints'|t(user.language) }}</th></tr>
<tr><td><code>profiles:read</code></td><td>{{ 'apikey.perm.profiles_read'|t(user.language) }}</td><td>GET /profiles, GET /profiles/{id}</td></tr>
<tr><td><code>profiles:write</code></td><td>{{ 'apikey.perm.profiles_write'|t(user.language) }}</td><td>POST /profiles, PUT /profiles/{id}, DELETE /profiles/{id}</td></tr>
<tr><td><code>postcards:read</code></td><td>{{ 'apikey.perm.postcards_read'|t(user.language) }}</td><td>GET /profiles/{id}/postcards, GET /profiles/{id}/postcards/{id}</td></tr>
<tr><td><code>postcards:write</code></td><td>{{ 'apikey.perm.postcards_write'|t(user.language) }}</td><td>POST /profiles/{id}/postcards, PUT /profiles/{id}/postcards/{id}, DELETE /profiles/{id}/postcards/{id}</td></tr>
<tr><td><code>images:upload</code></td><td>{{ 'apikey.perm.images_upload'|t(user.language) }}</td><td>POST /profiles/{id}/postcards/{id}/upload</td></tr>
<tr><td colspan="3" style="color:var(--text-muted);font-style:italic">{{ 'api_docs.permissions_keys_note'|t(user.language) }}</td></tr>
</table>
<p class="doc-note" style="margin-top:.5rem">{{ 'api_docs.permissions_note'|t(user.language) }}</p>
</div>
<!-- ═══════ Profiles ═══════ -->
<div class="doc-section">
<h2>{{ 'api_docs.profile_ops'|t(user.language) }}</h2>

View File

@@ -10,12 +10,14 @@
{% block nav %}
<nav class="navbar">
<a href="/dashboard" class="nav-brand">{{ 'brand'|t(user.language if user else 'zh') }}</a>
{% if user is defined and user %}
<button class="nav-hamburger" onclick="document.querySelector('.nav-links').classList.toggle('open')"></button>
<div class="nav-links">
{% if user is defined and user %}
<a href="/profiles">{{ 'nav.profiles'|t(user.language) }}</a>
<a href="/u/{{ user.username }}" target="_blank">{{ 'nav.showcase'|t(user.language) }}</a>
<a href="/settings">{{ 'nav.settings'|t(user.language) }}</a>
{% if user.is_admin %}
<a href="/admin/invites">{{ 'nav.invites'|t(user.language) }}</a>
<a href="/admin">{{ 'nav.admin'|t(user.language) }}</a>
{% endif %}
<a href="/api/docs">{{ 'nav.api_docs'|t(user.language) }}</a>
<div class="lang-switcher" id="langSwitcher">
@@ -30,13 +32,18 @@
</div>
</div>
<a href="/logout" class="nav-logout">{{ 'nav.logout'|t(user.language) }}</a>
{% endif %}
</div>
{% endif %}
</nav>
{% endblock %}
<main class="container">
{% block content %}{% endblock %}
</main>
{% if copyright_text %}
<footer style="text-align:center;padding:2rem 1rem;color:var(--text-muted);font-size:.8rem;border-top:1px solid var(--border);margin-top:2rem">
{{ copyright_text }}
</footer>
{% endif %}
</body>
<script>
// Auto-trim whitespace from all text inputs and textareas on form submit

View File

@@ -170,31 +170,63 @@
</div>
{% endif %}
<form method="post" action="/settings/apikeys/create" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem">
<input type="text" name="name" placeholder="{{ 'apikey.name_ph'|t(user.language) }}" style="max-width:200px">
<button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.create'|t(user.language) }}</button>
<form method="post" action="/settings/apikeys/create" style="margin-bottom:1rem">
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:.75rem">
<input type="text" name="name" placeholder="{{ 'apikey.name_ph'|t(user.language) }}" style="max-width:200px">
<button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.create'|t(user.language) }}</button>
</div>
<details>
<summary style="font-size:.85rem;color:var(--text-muted);cursor:pointer;margin-bottom:.5rem">{{ 'apikey.permissions'|t(user.language) }}</summary>
<div style="display:flex;flex-wrap:wrap;gap:.75rem 1.5rem">
{% for scope, label in [
('profiles:read', 'apikey.perm.profiles_read'),
('profiles:write', 'apikey.perm.profiles_write'),
('postcards:read', 'apikey.perm.postcards_read'),
('postcards:write', 'apikey.perm.postcards_write'),
('images:upload', 'apikey.perm.images_upload')
] %}
<label style="display:flex;align-items:center;gap:.3rem;font-size:.85rem;white-space:nowrap">
<input type="checkbox" name="permissions" value="{{ scope }}">
{{ label|t(user.language) }}
</label>
{% endfor %}
</div>
<p style="font-size:.78rem;color:var(--text-muted);margin-top:.5rem">{{ 'apikey.perm.hint'|t(user.language) }}</p>
</details>
</form>
{% if api_keys %}
<div class="table-wrap">
<table class="table">
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch">
<table class="table" style="min-width:700px;white-space:nowrap">
<thead>
<tr>
<th>{{ 'apikey.name'|t(user.language) }}</th>
<th>{{ 'apikey.key'|t(user.language) }}</th>
<th>{{ 'apikey.permissions'|t(user.language) }}</th>
<th>{{ 'apikey.created_at'|t(user.language) }}</th>
<th>{{ 'apikey.last_used'|t(user.language) }}</th>
<th></th>
<th style="white-space:nowrap"></th>
</tr>
</thead>
<tbody>
{% for ak in api_keys %}
{% set perms = ak.permissions|default('[]', true)|json_loads %}
<tr>
<td>{{ ak.name or '—' }}</td>
<td><code style="font-size:.8rem">{{ ak.key[:8] }}****</code></td>
<td>{{ ak.created_at|to_local|strftime('%Y-%m-%d') }}</td>
<td>{% if ak.last_used_at %}{{ ak.last_used_at|to_local|strftime('%Y-%m-%d') }}{% else %}—{% endif %}</td>
<td>
<td style="white-space:nowrap"><strong>{{ ak.name or '—' }}</strong></td>
<td><code style="font-size:.78rem">{{ ak.key[:8] }}****</code></td>
<td style="white-space:nowrap">
{% if not perms %}
<span class="badge" style="background:var(--sent);font-size:.75rem">{{ 'apikey.perm_full_access'|t(user.language) }}</span>
{% else %}
{% for p in perms %}
<span class="badge" style="font-size:.7rem;margin:1px">{{ p }}</span>
{% endfor %}
{% endif %}
</td>
<td style="white-space:nowrap;font-size:.82rem">{{ ak.created_at|to_local|strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td style="white-space:nowrap;font-size:.82rem">{% if ak.last_used_at %}{{ ak.last_used_at|to_local|strftime('%Y-%m-%d %H:%M:%S') }}{% else %}—{% endif %}</td>
<td style="white-space:nowrap">
<button type="button" class="btn btn-sm" onclick="toggleAkEdit({{ ak.id }})">{{ 'apikey.edit'|t(user.language) }}</button>
<form method="post" action="/settings/apikeys/{{ ak.id }}/delete" style="display:inline" onsubmit="return confirm('{{ 'apikey.confirm_delete'|t(user.language) }}')">
<button type="submit" class="btn btn-danger btn-sm">{{ 'apikey.delete'|t(user.language) }}</button>
</form>
@@ -204,6 +236,38 @@
</tbody>
</table>
</div>
{% for ak in api_keys %}
{% set perms = ak.permissions|default('[]', true)|json_loads %}
<div id="ak-edit-{{ ak.id }}" style="display:none;margin-top:.5rem;background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:1rem">
<h4 style="font-size:.85rem;margin-bottom:.75rem;color:var(--text-muted)">{{ 'apikey.edit'|t(user.language) }}: {{ ak.name or '—' }}</h4>
<form method="post" action="/settings/apikeys/{{ ak.id }}/edit" style="display:flex;flex-direction:column;gap:.5rem">
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<label style="font-size:.85rem;white-space:nowrap">{{ 'apikey.name'|t(user.language) }}</label>
<input type="text" name="name" value="{{ ak.name }}" style="max-width:200px">
</div>
<div style="display:flex;flex-wrap:wrap;gap:.6rem 1.25rem">
{% for scope, label in [
('profiles:read', 'apikey.perm.profiles_read'),
('profiles:write', 'apikey.perm.profiles_write'),
('postcards:read', 'apikey.perm.postcards_read'),
('postcards:write', 'apikey.perm.postcards_write'),
('images:upload', 'apikey.perm.images_upload')
] %}
<label style="display:flex;align-items:center;gap:.3rem;font-size:.85rem;white-space:nowrap">
<input type="checkbox" name="permissions" value="{{ scope }}" {% if scope in perms %}checked{% endif %}>
{{ label|t(user.language) }}
</label>
{% endfor %}
</div>
<div style="display:flex;gap:.5rem;margin-top:.25rem">
<button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.save'|t(user.language) }}</button>
<button type="button" class="btn btn-sm" onclick="toggleAkEdit({{ ak.id }})">{{ 'apikey.cancel'|t(user.language) }}</button>
</div>
</form>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>{{ 'apikey.empty'|t(user.language) }}</p>
@@ -219,5 +283,9 @@ function switchTab(name) {
event.currentTarget.classList.add('active');
document.getElementById('tab-' + name).style.display = '';
}
function toggleAkEdit(id) {
var el = document.getElementById('ak-edit-' + id);
if (el) el.style.display = el.style.display === 'none' ? '' : 'none';
}
</script>
{% endblock %}