DUNGEONS-34: implement Webka local streaming app
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
315
frontend/pages/host.vue
Normal file
315
frontend/pages/host.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Video,
|
||||
SwitchCamera,
|
||||
MonitorUp,
|
||||
ArrowUpRight,
|
||||
Copy,
|
||||
Check,
|
||||
ShieldCheck,
|
||||
UsersRound,
|
||||
Radio,
|
||||
Square,
|
||||
Maximize2,
|
||||
Wifi,
|
||||
RefreshCw,
|
||||
} from "lucide-vue-next";
|
||||
import QRCode from "qrcode";
|
||||
const { t } = useI18n();
|
||||
useHead({ title: computed(() => `${t("nav.host")} · Webka`) });
|
||||
const {
|
||||
media,
|
||||
state,
|
||||
error,
|
||||
source,
|
||||
rearAvailable,
|
||||
screenAvailable,
|
||||
quality,
|
||||
capture,
|
||||
start,
|
||||
stop,
|
||||
setQuality,
|
||||
} = useStream("host");
|
||||
interface HostStatus {
|
||||
live: boolean;
|
||||
since: number | null;
|
||||
viewer_count: number;
|
||||
viewers: { id: string; username: string; session_id: string }[];
|
||||
local_url: string;
|
||||
}
|
||||
const status = ref<HostStatus | null>(null);
|
||||
const qr = ref(""),
|
||||
copied = ref(false),
|
||||
busy = ref(false),
|
||||
networkError = ref(""),
|
||||
tick = ref(Date.now()),
|
||||
preview = ref<HTMLElement>();
|
||||
const apiError = useApiError();
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
const live = computed(() => state.value === "live");
|
||||
const elapsed = computed(() => {
|
||||
const seconds = status.value?.since
|
||||
? Math.max(0, Math.floor(tick.value / 1000) - status.value.since)
|
||||
: 0;
|
||||
return `${Math.floor(seconds / 3600)
|
||||
.toString()
|
||||
.padStart(2, "0")}:${Math.floor((seconds % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, "0")}:${(seconds % 60).toString().padStart(2, "0")}`;
|
||||
});
|
||||
async function refresh() {
|
||||
try {
|
||||
status.value = await $fetch<HostStatus>("/api/status");
|
||||
networkError.value = "";
|
||||
if (status.value?.local_url)
|
||||
qr.value = await QRCode.toDataURL(status.value.local_url, {
|
||||
margin: 1,
|
||||
width: 176,
|
||||
color: { dark: "#111418", light: "#ffffff" },
|
||||
});
|
||||
} catch (e) {
|
||||
networkError.value = apiError(e);
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
timer = setInterval(() => {
|
||||
tick.value = Date.now();
|
||||
void refresh();
|
||||
}, 3000);
|
||||
});
|
||||
onBeforeUnmount(() => clearInterval(timer));
|
||||
async function choose(value: "front" | "back" | "screen") {
|
||||
busy.value = true;
|
||||
await capture(value);
|
||||
busy.value = false;
|
||||
}
|
||||
async function toggle() {
|
||||
busy.value = true;
|
||||
if (state.value !== "offline") stop();
|
||||
else await start();
|
||||
busy.value = false;
|
||||
await refresh();
|
||||
}
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(status.value?.local_url ?? "");
|
||||
copied.value = true;
|
||||
setTimeout(() => (copied.value = false), 1800);
|
||||
} catch {
|
||||
networkError.value = t("errors.clipboard");
|
||||
}
|
||||
}
|
||||
async function kick(sid: string) {
|
||||
try {
|
||||
await $fetch(`/api/sessions/${sid}`, { method: "DELETE" });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
networkError.value = apiError(e);
|
||||
}
|
||||
}
|
||||
async function fullscreen() {
|
||||
try {
|
||||
await preview.value?.requestFullscreen();
|
||||
} catch {
|
||||
networkError.value = t("errors.fullscreen");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="page host-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<div class="eyebrow">{{ $t("host.eyebrow") }}</div>
|
||||
<h1>{{ $t("host.title") }}<span class="heading-dot">.</span></h1>
|
||||
<p class="muted">{{ $t("host.subtitle") }}</p>
|
||||
</div>
|
||||
<GlassButton
|
||||
:variant="live ? 'danger' : 'primary'"
|
||||
:disabled="busy"
|
||||
@click="toggle"
|
||||
><Square v-if="live" :size="16" fill="currentColor" /><Radio
|
||||
v-else
|
||||
:size="18"
|
||||
/>{{
|
||||
$t(state !== "offline" ? "host.stop" : "host.start")
|
||||
}}</GlassButton
|
||||
>
|
||||
</div>
|
||||
<p v-if="error || networkError" role="alert" class="error">
|
||||
{{ error || networkError }}
|
||||
</p>
|
||||
<div class="host-grid">
|
||||
<section class="feed-section">
|
||||
<div ref="preview" class="preview">
|
||||
<StreamVideo
|
||||
v-if="media"
|
||||
:stream="media"
|
||||
:mirror="source === 'front'"
|
||||
/>
|
||||
<div v-else class="preview-empty">
|
||||
<div class="camera-orbit">
|
||||
<Video :size="38" :stroke-width="1.3" />
|
||||
</div>
|
||||
<h2>{{ $t("host.previewTitle") }}</h2>
|
||||
<p>{{ $t("host.previewHint") }}</p>
|
||||
<GlassButton :disabled="busy" @click="choose(source)"
|
||||
><Video :size="17" />{{ $t("host.enableCamera") }}</GlassButton
|
||||
>
|
||||
</div>
|
||||
<div class="preview-top">
|
||||
<span class="glass status-pill" role="status"
|
||||
><span class="status-dot" :class="{ live }" />{{
|
||||
$t(`stream.${state}`)
|
||||
}}</span
|
||||
><span class="glass private-pill"
|
||||
><ShieldCheck :size="14" />{{ $t("host.private") }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="media" class="preview-bottom">
|
||||
<span class="glass status-pill">{{ $t(`sources.${source}`) }}</span
|
||||
><GlassButton
|
||||
class="icon-button"
|
||||
:aria-label="$t('watch.fullscreen')"
|
||||
@click="fullscreen"
|
||||
><Maximize2 :size="18"
|
||||
/></GlassButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feed-caption">
|
||||
<span
|
||||
><span class="status-dot" :class="{ live }" />{{
|
||||
$t(live ? "host.broadcasting" : "host.previewOnly")
|
||||
}}</span
|
||||
><span>{{ $t("host.audioOff") }}</span>
|
||||
</div>
|
||||
<GlassCard class="source-toolbar"
|
||||
><div class="toolbar-label">
|
||||
<span class="eyebrow">{{ $t("host.videoSource") }}</span
|
||||
><small>{{ $t("host.switchAnytime") }}</small>
|
||||
</div>
|
||||
<div class="source-options">
|
||||
<button
|
||||
:class="{ selected: source === 'front' }"
|
||||
:disabled="busy"
|
||||
@click="choose('front')"
|
||||
>
|
||||
<Video :size="20" /><span>{{ $t("sources.front") }}</span></button
|
||||
><button
|
||||
:class="{ selected: source === 'back' }"
|
||||
:disabled="busy || !rearAvailable"
|
||||
:title="!rearAvailable ? $t('host.rearHint') : undefined"
|
||||
@click="choose('back')"
|
||||
>
|
||||
<SwitchCamera :size="20" /><span>{{
|
||||
$t("sources.back")
|
||||
}}</span></button
|
||||
><button
|
||||
:class="{ selected: source === 'screen' }"
|
||||
:disabled="busy || !screenAvailable"
|
||||
@click="choose('screen')"
|
||||
>
|
||||
<MonitorUp :size="20" /><span>{{ $t("sources.screen") }}</span>
|
||||
</button>
|
||||
</div></GlassCard
|
||||
>
|
||||
<div class="stats-row">
|
||||
<div>
|
||||
<span class="muted">{{ $t("host.uptime") }}</span
|
||||
><strong class="mono">{{ elapsed }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="muted">{{ $t("host.viewers") }}</span
|
||||
><strong
|
||||
>{{ status?.viewer_count ?? 0 }}<small> / 31</small></strong
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="muted" for="quality">{{ $t("host.quality") }}</label
|
||||
><select
|
||||
id="quality"
|
||||
:value="quality"
|
||||
@change="setQuality(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="auto">{{ $t("quality.auto") }}</option>
|
||||
<option value="high">{{ $t("quality.high") }}</option>
|
||||
<option value="low">{{ $t("quality.low") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="host-aside">
|
||||
<GlassCard class="share-panel"
|
||||
><div class="section-heading">
|
||||
<h2>{{ $t("host.connectDevice") }}</h2>
|
||||
<ArrowUpRight :size="20" />
|
||||
</div>
|
||||
<p class="muted">{{ $t("host.scanHint") }}</p>
|
||||
<div class="qr-wrap">
|
||||
<img
|
||||
v-if="qr"
|
||||
:src="qr"
|
||||
:alt="$t('host.qrAlt')"
|
||||
width="154"
|
||||
height="154"
|
||||
/>
|
||||
<div v-else class="qr-loading"><RefreshCw :size="24" /></div>
|
||||
</div>
|
||||
<div class="url-copy">
|
||||
<input
|
||||
:value="status?.local_url ?? ''"
|
||||
readonly
|
||||
:aria-label="$t('host.localAddress')"
|
||||
/><GlassButton
|
||||
class="icon-button"
|
||||
:disabled="!status"
|
||||
:aria-label="$t('host.copy')"
|
||||
@click="copy"
|
||||
><Check v-if="copied" :size="18" /><Copy v-else :size="18"
|
||||
/></GlassButton>
|
||||
</div>
|
||||
<span class="local-note"
|
||||
><Wifi :size="14" />{{
|
||||
$t(copied ? "host.copied" : "host.sameNetwork")
|
||||
}}</span
|
||||
></GlassCard
|
||||
>
|
||||
<section class="viewers-panel">
|
||||
<div class="section-heading">
|
||||
<h2>
|
||||
{{ $t("host.connected")
|
||||
}}<span class="count-badge">{{ status?.viewer_count ?? 0 }}</span>
|
||||
</h2>
|
||||
<UsersRound :size="19" />
|
||||
</div>
|
||||
<div v-if="!status?.viewers.length" class="empty-viewers">
|
||||
<UsersRound :size="28" :stroke-width="1.3" />
|
||||
<p>{{ $t("host.noViewers") }}</p>
|
||||
<small>{{ $t("host.inviteHint") }}</small>
|
||||
</div>
|
||||
<ul v-else class="viewer-list">
|
||||
<li v-for="viewer in status.viewers" :key="viewer.id">
|
||||
<span class="avatar">{{
|
||||
viewer.username.slice(0, 1).toUpperCase()
|
||||
}}</span
|
||||
><span
|
||||
>{{ viewer.username
|
||||
}}<small>{{ $t("stream.live") }}</small></span
|
||||
><GlassButton
|
||||
class="small-button"
|
||||
@click="kick(viewer.session_id)"
|
||||
>{{ $t("host.kick") }}</GlassButton
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<NuxtLink class="text-link" to="/settings/accounts"
|
||||
>{{ $t("host.manageAccess") }}<ArrowUpRight :size="16"
|
||||
/></NuxtLink>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
<footer class="page-footer">
|
||||
<ShieldCheck :size="16" />{{ $t("host.footer") }}<span>WEBKA / LAN</span>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
1
frontend/pages/index.vue
Normal file
1
frontend/pages/index.vue
Normal file
@@ -0,0 +1 @@
|
||||
<template><div /></template>
|
||||
140
frontend/pages/login.vue
Normal file
140
frontend/pages/login.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpRight, LockKeyhole, Video, ShieldCheck } from "lucide-vue-next";
|
||||
const { t } = useI18n();
|
||||
useHead({ title: computed(() => `${t("login.title")} · Webka`) });
|
||||
const session = useSessionStore();
|
||||
const username = ref(""),
|
||||
password = ref(""),
|
||||
token = ref(""),
|
||||
setup = ref(false),
|
||||
loading = ref(true),
|
||||
error = ref("");
|
||||
const apiError = useApiError();
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (await session.refresh()) {
|
||||
await navigateTo(session.user?.role === "admin" ? "/host" : "/watch");
|
||||
return;
|
||||
}
|
||||
setup.value = (
|
||||
await $fetch<{ needs_setup: boolean }>("/api/bootstrap")
|
||||
).needs_setup;
|
||||
} catch (e) {
|
||||
error.value = apiError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
if (setup.value)
|
||||
await $fetch("/api/bootstrap", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
setup_token: token.value,
|
||||
},
|
||||
});
|
||||
await $fetch("/api/login", {
|
||||
method: "POST",
|
||||
body: { username: username.value, password: password.value },
|
||||
});
|
||||
await session.refresh();
|
||||
await navigateTo(session.user?.role === "admin" ? "/host" : "/watch");
|
||||
} catch (e) {
|
||||
error.value = apiError(e);
|
||||
try {
|
||||
setup.value = (
|
||||
await $fetch<{ needs_setup: boolean }>("/api/bootstrap")
|
||||
).needs_setup;
|
||||
} catch {}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="login-layout">
|
||||
<div class="login-intro">
|
||||
<span class="eyebrow"
|
||||
><span class="status-dot" />{{ $t("login.local") }}</span
|
||||
>
|
||||
<h1>
|
||||
{{ $t("login.headline") }}<br /><span>{{
|
||||
$t("login.headlineAccent")
|
||||
}}</span>
|
||||
</h1>
|
||||
<p>{{ $t("login.description") }}</p>
|
||||
<div class="login-principle">
|
||||
<ShieldCheck :size="22" /><span>{{ $t("login.privacy") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<GlassCard class="login-card"
|
||||
><div class="login-icon"><Video :size="27" /></div>
|
||||
<h2>{{ $t(setup ? "login.setupTitle" : "login.title") }}</h2>
|
||||
<p class="muted">
|
||||
{{ $t(setup ? "login.setupDescription" : "login.subtitle") }}
|
||||
</p>
|
||||
<form @submit.prevent="submit">
|
||||
<label
|
||||
>{{ $t("accounts.username")
|
||||
}}<input
|
||||
v-model="username"
|
||||
autocomplete="username"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="40"
|
||||
pattern="[A-Za-z0-9_.\-]+"
|
||||
:placeholder="$t('login.usernamePlaceholder')"
|
||||
/></label>
|
||||
<label
|
||||
>{{ $t("accounts.password")
|
||||
}}<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
:autocomplete="setup ? 'new-password' : 'current-password'"
|
||||
required
|
||||
:minlength="setup ? 12 : 1"
|
||||
maxlength="256"
|
||||
:pattern="
|
||||
setup ? '(?=.*[A-Za-zА-Яа-яЁёÀ-ÿ])(?=.*[0-9]).{12,}' : undefined
|
||||
"
|
||||
:placeholder="$t('login.passwordPlaceholder')"
|
||||
/></label>
|
||||
<template v-if="setup"
|
||||
><p class="field-help">{{ $t("accounts.passwordHint") }}</p>
|
||||
<label
|
||||
>{{ $t("login.setupToken")
|
||||
}}<input v-model="token" required autocomplete="off"
|
||||
/></label>
|
||||
<p class="field-help">{{ $t("login.tokenHint") }}</p></template
|
||||
>
|
||||
<p v-if="error" role="alert" class="error">{{ error }}</p>
|
||||
<GlassButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
class="full-width"
|
||||
:disabled="loading"
|
||||
>{{
|
||||
$t(
|
||||
loading
|
||||
? "common.working"
|
||||
: setup
|
||||
? "login.create"
|
||||
: "login.signin",
|
||||
)
|
||||
}}<ArrowUpRight :size="19"
|
||||
/></GlassButton>
|
||||
</form>
|
||||
<div class="login-foot">
|
||||
<LockKeyhole :size="14" />{{ $t("login.hostAccounts") }}
|
||||
</div>
|
||||
</GlassCard>
|
||||
<div class="login-bottom">
|
||||
WEBKA <span>01 / {{ $t("login.localUtility") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
292
frontend/pages/settings/accounts.vue
Normal file
292
frontend/pages/settings/accounts.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
KeyRound,
|
||||
Trash2,
|
||||
UsersRound,
|
||||
} from "lucide-vue-next";
|
||||
interface Account {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
last_login: number | null;
|
||||
active_sessions: number;
|
||||
}
|
||||
interface Session {
|
||||
id: string;
|
||||
username: string;
|
||||
last_seen: number;
|
||||
created: number;
|
||||
}
|
||||
const { t, locale } = useI18n();
|
||||
useHead({ title: computed(() => `${t("accounts.title")} · Webka`) });
|
||||
const current = useSessionStore();
|
||||
const accounts = ref<Account[]>([]),
|
||||
sessions = ref<Session[]>([]),
|
||||
loading = ref(true),
|
||||
busy = ref(false),
|
||||
error = ref(""),
|
||||
success = ref("");
|
||||
const modal = ref<"create" | "reset" | "delete" | null>(null),
|
||||
selected = ref<Account | null>(null),
|
||||
username = ref(""),
|
||||
password = ref("");
|
||||
const apiError = useApiError();
|
||||
async function load() {
|
||||
try {
|
||||
[accounts.value, sessions.value] = await Promise.all([
|
||||
$fetch<Account[]>("/api/accounts"),
|
||||
$fetch<Session[]>("/api/sessions"),
|
||||
]);
|
||||
} catch (e) {
|
||||
error.value = apiError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
function open(
|
||||
action: "create" | "reset" | "delete",
|
||||
account: Account | null = null,
|
||||
) {
|
||||
modal.value = action;
|
||||
selected.value = account;
|
||||
username.value = "";
|
||||
password.value = "";
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
}
|
||||
function date(value: number | null) {
|
||||
return value
|
||||
? new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(value * 1000)
|
||||
: t("accounts.never");
|
||||
}
|
||||
async function submit() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (modal.value === "create")
|
||||
await $fetch("/api/accounts", {
|
||||
method: "POST",
|
||||
body: { username: username.value, password: password.value },
|
||||
});
|
||||
if (modal.value === "reset")
|
||||
await $fetch(`/api/accounts/${selected.value!.id}/password`, {
|
||||
method: "POST",
|
||||
body: { password: password.value },
|
||||
});
|
||||
if (modal.value === "delete")
|
||||
await $fetch(`/api/accounts/${selected.value!.id}`, { method: "DELETE" });
|
||||
if (modal.value === "reset" && selected.value?.id === current.user?.id) {
|
||||
modal.value = null;
|
||||
await current.refresh();
|
||||
await navigateTo("/login");
|
||||
return;
|
||||
}
|
||||
modal.value = null;
|
||||
success.value = t("accounts.saved");
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function revoke(id: string) {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await $fetch(`/api/sessions/${id}`, { method: "DELETE" });
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="page settings-page">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<div class="eyebrow">{{ $t("accounts.eyebrow") }}</div>
|
||||
<h1>{{ $t("accounts.title") }}<span class="heading-dot">.</span></h1>
|
||||
<p class="muted">{{ $t("accounts.subtitle") }}</p>
|
||||
</div>
|
||||
<GlassButton variant="primary" @click="open('create')"
|
||||
><Plus :size="18" />{{ $t("accounts.add") }}</GlassButton
|
||||
>
|
||||
</div>
|
||||
<p v-if="error && !modal" role="alert" class="error">{{ error }}</p>
|
||||
<p v-if="success" role="status" class="success">{{ success }}</p>
|
||||
<div class="security-banner">
|
||||
<ShieldCheck :size="23" />
|
||||
<div>
|
||||
<strong>{{ $t("accounts.protected") }}</strong>
|
||||
<p>{{ $t("accounts.protectedHint") }}</p>
|
||||
</div>
|
||||
<span class="status-dot live" />
|
||||
</div>
|
||||
<section class="content-panel">
|
||||
<div class="section-heading">
|
||||
<h2><UsersRound :size="20" />{{ $t("accounts.localAccounts") }}</h2>
|
||||
<span class="count-badge">{{ accounts.length }}</span>
|
||||
</div>
|
||||
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||
<div v-else class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t("accounts.account") }}</th>
|
||||
<th>{{ $t("accounts.role") }}</th>
|
||||
<th>{{ $t("accounts.lastLogin") }}</th>
|
||||
<th>{{ $t("accounts.sessions") }}</th>
|
||||
<th>
|
||||
<span class="sr-only">{{ $t("accounts.actions") }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="account in accounts" :key="account.id">
|
||||
<td>
|
||||
<span class="account-name"
|
||||
><span class="avatar">{{
|
||||
account.username[0]?.toUpperCase()
|
||||
}}</span
|
||||
>{{ account.username
|
||||
}}<small v-if="account.id === current.user?.id">{{
|
||||
$t("accounts.you")
|
||||
}}</small></span
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
<span class="role-tag" :class="account.role">{{
|
||||
$t(`accounts.${account.role}`)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="muted">{{ date(account.last_login) }}</td>
|
||||
<td>{{ account.active_sessions }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<GlassButton
|
||||
class="icon-button"
|
||||
:aria-label="`${$t('accounts.reset')} — ${account.username}`"
|
||||
@click="open('reset', account)"
|
||||
><KeyRound :size="17" /></GlassButton
|
||||
><GlassButton
|
||||
v-if="account.role !== 'admin'"
|
||||
class="icon-button"
|
||||
:aria-label="`${$t('accounts.delete')} — ${account.username}`"
|
||||
@click="open('delete', account)"
|
||||
><Trash2 :size="17"
|
||||
/></GlassButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="content-panel">
|
||||
<div class="section-heading">
|
||||
<h2>{{ $t("accounts.activeSessions") }}</h2>
|
||||
<GlassButton class="small-button" @click="load">{{
|
||||
$t("common.refresh")
|
||||
}}</GlassButton>
|
||||
</div>
|
||||
<p class="muted">{{ $t("accounts.sessionHint") }}</p>
|
||||
<div class="session-row" v-for="session in sessions" :key="session.id">
|
||||
<span class="avatar">{{ session.username[0]?.toUpperCase() }}</span>
|
||||
<div>
|
||||
<strong
|
||||
>{{ session.username }}
|
||||
<small v-if="session.id === current.user?.session_id">{{
|
||||
$t("accounts.current")
|
||||
}}</small></strong
|
||||
><small
|
||||
>{{ $t("accounts.lastActive") }} ·
|
||||
{{ date(session.last_seen) }}</small
|
||||
>
|
||||
</div>
|
||||
<GlassButton
|
||||
v-if="session.id !== current.user?.session_id"
|
||||
class="small-button"
|
||||
:disabled="busy"
|
||||
@click="revoke(session.id)"
|
||||
>{{ $t("accounts.revoke") }}</GlassButton
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<GlassModal
|
||||
:open="!!modal"
|
||||
:title="
|
||||
$t(
|
||||
modal === 'create'
|
||||
? 'accounts.add'
|
||||
: modal === 'reset'
|
||||
? 'accounts.reset'
|
||||
: 'accounts.delete',
|
||||
)
|
||||
"
|
||||
@close="!busy && (modal = null)"
|
||||
><form @submit.prevent="submit">
|
||||
<template v-if="modal === 'create'"
|
||||
><label
|
||||
>{{ $t("accounts.username")
|
||||
}}<input
|
||||
v-model="username"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="40"
|
||||
pattern="[A-Za-z0-9_.\-]+"
|
||||
autocomplete="off"
|
||||
/></label>
|
||||
<p class="field-help">{{ $t("accounts.usernameHint") }}</p></template
|
||||
><template v-if="modal !== 'delete'"
|
||||
><label
|
||||
>{{ $t("accounts.password")
|
||||
}}<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
minlength="12"
|
||||
maxlength="256"
|
||||
pattern="(?=.*[A-Za-zА-Яа-яЁёÀ-ÿ])(?=.*[0-9]).{12,}"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<p class="field-help">{{ $t("accounts.passwordHint") }}</p>
|
||||
<p v-if="modal === 'reset'" class="muted">
|
||||
{{ $t("accounts.resetHint", { name: selected?.username }) }}
|
||||
</p></template
|
||||
>
|
||||
<p v-else>
|
||||
{{ $t("accounts.deleteHint", { name: selected?.username }) }}
|
||||
</p>
|
||||
<p v-if="error" role="alert" class="error">{{ error }}</p>
|
||||
<div class="modal-actions">
|
||||
<GlassButton :disabled="busy" @click="modal = null">{{
|
||||
$t("common.cancel")
|
||||
}}</GlassButton
|
||||
><GlassButton
|
||||
:variant="modal === 'delete' ? 'danger' : 'primary'"
|
||||
type="submit"
|
||||
:disabled="busy"
|
||||
>{{
|
||||
$t(
|
||||
busy
|
||||
? "common.working"
|
||||
: modal === "delete"
|
||||
? "accounts.delete"
|
||||
: "common.save",
|
||||
)
|
||||
}}</GlassButton
|
||||
>
|
||||
</div>
|
||||
</form></GlassModal
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
118
frontend/pages/settings/general.vue
Normal file
118
frontend/pages/settings/general.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Moon,
|
||||
Sun,
|
||||
Check,
|
||||
Globe2,
|
||||
Palette,
|
||||
ShieldCheck,
|
||||
} from "lucide-vue-next";
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
useHead({ title: computed(() => `${t("general.title")} · Webka`) });
|
||||
const theme = useCookie("webka_theme", { default: () => "dark" });
|
||||
const accent = useCookie("webka_accent", { default: () => "#c3f66b" });
|
||||
const colors = [
|
||||
{ color: "#c3f66b", key: "lime" },
|
||||
{ color: "#91c9ff", key: "blue" },
|
||||
{ color: "#c9afff", key: "violet" },
|
||||
{ color: "#ffc899", key: "peach" },
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<div class="page settings-page narrow">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<div class="eyebrow">{{ $t("general.eyebrow") }}</div>
|
||||
<h1>{{ $t("general.title") }}<span class="heading-dot">.</span></h1>
|
||||
<p class="muted">{{ $t("general.subtitle") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<section class="content-panel">
|
||||
<div class="section-heading">
|
||||
<h2><Palette :size="21" />{{ $t("general.appearance") }}</h2>
|
||||
</div>
|
||||
<div class="theme-options">
|
||||
<button :class="{ selected: theme === 'dark' }" @click="theme = 'dark'">
|
||||
<div class="theme-preview dark-preview"><span /><span /><span /></div>
|
||||
<span
|
||||
><Moon :size="18" />{{ $t("general.dark")
|
||||
}}<Check v-if="theme === 'dark'" :size="17"
|
||||
/></span></button
|
||||
><button
|
||||
:class="{ selected: theme === 'light' }"
|
||||
@click="theme = 'light'"
|
||||
>
|
||||
<div class="theme-preview light-preview">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
<span
|
||||
><Sun :size="18" />{{ $t("general.light")
|
||||
}}<Check v-if="theme === 'light'" :size="17"
|
||||
/></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<h3>{{ $t("general.accent") }}</h3>
|
||||
<p>{{ $t("general.accentHint") }}</p>
|
||||
</div>
|
||||
<div class="swatches">
|
||||
<button
|
||||
v-for="color in colors"
|
||||
:key="color.color"
|
||||
:style="{ background: color.color }"
|
||||
:aria-label="$t(`colors.${color.key}`)"
|
||||
:aria-pressed="accent === color.color"
|
||||
@click="accent = color.color"
|
||||
>
|
||||
<Check v-if="accent === color.color" :size="19" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="content-panel">
|
||||
<div class="section-heading">
|
||||
<h2><Globe2 :size="21" />{{ $t("general.language") }}</h2>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<h3>{{ $t("general.displayLanguage") }}</h3>
|
||||
<p>{{ $t("general.languageHint") }}</p>
|
||||
</div>
|
||||
<select
|
||||
:value="locale"
|
||||
:aria-label="$t('general.language')"
|
||||
@change="
|
||||
setLocale(
|
||||
($event.target as HTMLSelectElement).value as 'en' | 'ru' | 'es',
|
||||
)
|
||||
"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
<section class="content-panel">
|
||||
<div class="section-heading">
|
||||
<h2><ShieldCheck :size="21" />{{ $t("general.privacy") }}</h2>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<h3>{{ $t("general.loginRequired") }}</h3>
|
||||
<p>{{ $t("general.loginHint") }}</p>
|
||||
</div>
|
||||
<span class="role-tag admin">{{ $t("general.alwaysOn") }}</span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<h3>{{ $t("general.localOnly") }}</h3>
|
||||
<p>{{ $t("general.localHint") }}</p>
|
||||
</div>
|
||||
<span class="status-dot live" />
|
||||
</div>
|
||||
</section>
|
||||
<p class="settings-footnote">{{ $t("general.savedLocally") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
88
frontend/pages/watch.vue
Normal file
88
frontend/pages/watch.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
LogOut,
|
||||
Radio,
|
||||
Wifi,
|
||||
RotateCw,
|
||||
} from "lucide-vue-next";
|
||||
const { t } = useI18n();
|
||||
useHead({ title: computed(() => `${t("nav.watch")} · Webka`) });
|
||||
const session = useSessionStore();
|
||||
const { remote, state, error, resolution, start, stop } = useStream("viewer");
|
||||
const player = ref<HTMLElement>(),
|
||||
full = ref(false),
|
||||
actionError = ref("");
|
||||
onMounted(() => {
|
||||
void start();
|
||||
document.addEventListener("fullscreenchange", changed);
|
||||
});
|
||||
onBeforeUnmount(() =>
|
||||
document.removeEventListener("fullscreenchange", changed),
|
||||
);
|
||||
function changed() {
|
||||
full.value = !!document.fullscreenElement;
|
||||
}
|
||||
async function fullscreen() {
|
||||
try {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
else await player.value?.requestFullscreen();
|
||||
} catch {
|
||||
actionError.value = t("errors.fullscreen");
|
||||
}
|
||||
}
|
||||
async function disconnect() {
|
||||
stop();
|
||||
await session.logout();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div ref="player" class="watch-page">
|
||||
<StreamVideo v-if="remote" :stream="remote" />
|
||||
<div v-else class="watch-empty">
|
||||
<Radio :size="44" :stroke-width="1.2" />
|
||||
<div class="eyebrow">{{ $t(`stream.${state}`) }}</div>
|
||||
<h1>
|
||||
{{
|
||||
$t(
|
||||
state === "waiting"
|
||||
? "watch.waitingTitle"
|
||||
: "watch.connectingTitle",
|
||||
)
|
||||
}}
|
||||
</h1>
|
||||
<p>
|
||||
{{
|
||||
$t(state === "waiting" ? "watch.waitingHint" : "watch.connectingHint")
|
||||
}}
|
||||
</p>
|
||||
<GlassButton v-if="state === 'offline'" @click="start"
|
||||
><RotateCw :size="18" />{{ $t("watch.reconnect") }}</GlassButton
|
||||
>
|
||||
</div>
|
||||
<p v-if="error || actionError" class="error watch-error" role="alert">
|
||||
{{ error || actionError }}
|
||||
</p>
|
||||
<GlassCard class="watch-controls"
|
||||
><span class="watch-state" role="status"
|
||||
><span class="status-dot" :class="{ live: state === 'live' }" />{{
|
||||
$t(`stream.${state}`)
|
||||
}}</span
|
||||
><span class="watch-quality"
|
||||
><Wifi :size="17" />{{ resolution || $t("quality.auto") }}</span
|
||||
><GlassButton
|
||||
class="icon-button"
|
||||
:aria-label="$t('watch.fullscreen')"
|
||||
@click="fullscreen"
|
||||
><Minimize2 v-if="full" :size="20" /><Maximize2
|
||||
v-else
|
||||
:size="20" /></GlassButton
|
||||
><GlassButton @click="disconnect"
|
||||
><LogOut :size="18" /><span>{{
|
||||
$t("watch.disconnect")
|
||||
}}</span></GlassButton
|
||||
><LanguageSwitcher v-if="full"
|
||||
/></GlassCard>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user