419ea11b14
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
User-facing Feedback screen (Settings -> Info, registered accounts only): a message (<=1024 runes) plus one optional attachment, an anti-spam gate (one unreviewed message at a time), and the operator's inline reply with a Settings/Info badge. Server-rendered admin console section (/_gm/feedback): unread/read/archived queue with per-user search, detail with read/reply/ archive/delete/delete-all, safe attachment serving (nosniff, images inline via <img>, others download-only). Introduces account_roles, the first per-account role table; feedback_banned blocks only feedback submission, granted/revoked from /users and the delete-with-block action. - migration 00004_feedback (feedback_messages + account_roles) + jetgen - backend internal/feedback (store+service), internal/account/roles.go - wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest, is_guest via session resolve) -> guest_forbidden before any backend call - reply push reuses NotificationEvent with a new admin_reply sub-kind - UI: /feedback route + screen, attachment picker, badge, channel detection, i18n - tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e - docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs
65 lines
2.7 KiB
Svelte
65 lines
2.7 KiB
Svelte
<script lang="ts">
|
||
import Screen from '../components/Screen.svelte';
|
||
import TabBar from '../components/TabBar.svelte';
|
||
import Settings from './Settings.svelte';
|
||
import Profile from './Profile.svelte';
|
||
import Friends from './Friends.svelte';
|
||
import About from './About.svelte';
|
||
import { app } from '../lib/app.svelte';
|
||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||
|
||
// The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile /
|
||
// Friends / About bodies. Tabs switch in place (no navigation), so the back control
|
||
// always returns to the lobby. Guests have no social surface, so the Friends tab hides.
|
||
type SettingsTab = 'settings' | 'profile' | 'friends' | 'about';
|
||
let { initialTab = 'settings' }: { initialTab?: SettingsTab } = $props();
|
||
|
||
const guest = $derived(app.profile?.isGuest ?? true);
|
||
// The active tab is seeded once from the entry route's tab and then owned locally;
|
||
// the hub is keyed by route in App.svelte, so initialTab is constant for its lifetime.
|
||
// svelte-ignore state_referenced_locally
|
||
let tab = $state<SettingsTab>(initialTab);
|
||
// A guest who deep-links to the Friends tab falls back to Settings.
|
||
$effect(() => {
|
||
if (guest && tab === 'friends') tab = 'settings';
|
||
});
|
||
|
||
const titleKey: Record<SettingsTab, MessageKey> = {
|
||
settings: 'settings.title',
|
||
profile: 'profile.title',
|
||
friends: 'friends.title',
|
||
about: 'about.title',
|
||
};
|
||
</script>
|
||
|
||
<Screen title={t(titleKey[tab])} back="/">
|
||
{#if tab === 'settings'}
|
||
<Settings />
|
||
{:else if tab === 'profile'}
|
||
<Profile />
|
||
{:else if tab === 'friends'}
|
||
<Friends />
|
||
{:else}
|
||
<About />
|
||
{/if}
|
||
|
||
{#snippet tabbar()}
|
||
<TabBar>
|
||
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
|
||
<span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span>
|
||
</button>
|
||
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}>
|
||
<span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span>
|
||
</button>
|
||
{#if !guest}
|
||
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}>
|
||
<span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span>
|
||
</button>
|
||
{/if}
|
||
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
|
||
<span class="sq" aria-hidden="true">ℹ️{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span>
|
||
</button>
|
||
</TabBar>
|
||
{/snippet}
|
||
</Screen>
|