DUNGEONS-34: implement Webka local streaming app

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-06 16:23:09 +05:00
parent 5540b9a1f4
commit 7c737c1011
36 changed files with 21596 additions and 0 deletions

53
frontend/app.vue Normal file
View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
const { locale } = useI18n();
const session = useSessionStore();
const theme = useCookie("webka_theme", { default: () => "dark" });
const accent = useCookie("webka_accent", { default: () => "#c3f66b" });
useHead({
htmlAttrs: { lang: locale, "data-theme": theme },
bodyAttrs: {
style: computed(
() =>
`--accent: ${/^#[a-f\d]{6}$/i.test(accent.value) ? accent.value : "#c3f66b"}`,
),
},
});
let heartbeat: ReturnType<typeof setInterval>;
onMounted(() => {
heartbeat = setInterval(async () => {
if (
session.user &&
document.visibilityState === "visible" &&
!(await session.refresh())
)
await navigateTo("/login");
}, 60000);
});
onBeforeUnmount(() => clearInterval(heartbeat));
</script>
<template>
<div class="app-shell">
<svg class="glass-filter" aria-hidden="true" width="0" height="0">
<defs>
<filter id="glass-lens" x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.012 0.018"
numOctaves="2"
seed="8"
result="noise"
/>
<feDisplacementMap
in="SourceGraphic"
in2="noise"
scale="3"
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</defs></svg
><a class="skip-link" href="#main">{{ $t("common.skip") }}</a
><GlassNavBar />
<main id="main"><NuxtPage /></main>
</div>
</template>

1706
frontend/assets/css/main.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: "primary" | "danger" | "quiet";
type?: "button" | "submit";
disabled?: boolean;
}>(),
{ variant: "quiet", type: "button" },
);
const ripple = ref(false);
let timer: ReturnType<typeof setTimeout>;
function press() {
ripple.value = false;
requestAnimationFrame(() => {
ripple.value = true;
timer = setTimeout(() => (ripple.value = false), 500);
});
}
onBeforeUnmount(() => clearTimeout(timer));
</script>
<template>
<button
:type="type"
:disabled="disabled"
class="glass-button"
:class="[variant, { ripple }]"
@pointerdown="press"
>
<slot />
</button>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{ as?: string }>(), { as: "section" });
function highlight(e: PointerEvent) {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const element = e.currentTarget as HTMLElement;
const r = element.getBoundingClientRect();
element.style.setProperty("--glass-x", `${e.clientX - r.left}px`);
element.style.setProperty("--glass-y", `${e.clientY - r.top}px`);
}
</script>
<template>
<component :is="props.as" class="glass" @pointermove="highlight"
><slot
/></component>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { X } from "lucide-vue-next";
const props = defineProps<{ open: boolean; title: string }>();
const emit = defineEmits<{ close: [] }>();
const dialog = ref<HTMLDialogElement>();
const titleId = useId();
watch(
() => props.open,
async (open) => {
await nextTick();
if (open) dialog.value?.showModal();
else dialog.value?.close();
},
);
onMounted(() => {
if (props.open) dialog.value?.showModal();
});
</script>
<template>
<dialog
ref="dialog"
class="glass modal"
:aria-labelledby="titleId"
@cancel.prevent="emit('close')"
@click="
(e) => {
if (e.target === dialog) emit('close');
}
"
>
<div class="section-heading">
<h2 :id="titleId">{{ title }}</h2>
<GlassButton
:aria-label="$t('common.close')"
class="icon-button"
@click="emit('close')"
><X :size="20"
/></GlassButton>
</div>
<slot />
</dialog>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import {
Radio,
UsersRound,
SlidersHorizontal,
LogOut,
Eye,
Video,
} from "lucide-vue-next";
const session = useSessionStore();
const compact = ref(false);
function scroll() {
compact.value = window.scrollY > 40;
}
onMounted(() => window.addEventListener("scroll", scroll, { passive: true }));
onBeforeUnmount(() => window.removeEventListener("scroll", scroll));
</script>
<template>
<header class="topbar" :class="{ compact }">
<NuxtLink
:to="session.user?.role === 'admin' ? '/host' : '/watch'"
class="brand"
aria-label="Webka"
><span class="brand-mark"><Video :size="22" /></span>webka<span
class="brand-dot"
>.</span
></NuxtLink
>
<GlassCard
v-if="session.user"
as="nav"
class="main-nav"
:aria-label="$t('nav.main')"
>
<NuxtLink v-if="session.user.role === 'admin'" to="/host"
><Radio :size="17" /><span>{{ $t("nav.host") }}</span></NuxtLink
>
<NuxtLink v-else to="/watch"
><Eye :size="17" /><span>{{ $t("nav.watch") }}</span></NuxtLink
>
<NuxtLink v-if="session.user.role === 'admin'" to="/settings/accounts"
><UsersRound :size="17" /><span>{{
$t("nav.accounts")
}}</span></NuxtLink
>
<NuxtLink to="/settings/general"
><SlidersHorizontal :size="17" /><span>{{
$t("nav.settings")
}}</span></NuxtLink
>
</GlassCard>
<div class="topbar-actions">
<LanguageSwitcher /><GlassButton
v-if="session.user"
class="icon-button logout"
:aria-label="$t('common.logout')"
@click="session.logout"
><LogOut :size="19"
/></GlassButton>
</div>
</header>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { Globe2, ChevronDown } from "lucide-vue-next";
const { locale, setLocale } = useI18n();
const open = ref(false);
const root = ref<HTMLElement>();
const languages = [
{ code: "en", name: "English" },
{ code: "ru", name: "Русский" },
{ code: "es", name: "Español" },
] as const;
function outside(e: PointerEvent) {
if (!root.value?.contains(e.target as Node)) open.value = false;
}
onMounted(() => document.addEventListener("pointerdown", outside));
onBeforeUnmount(() => document.removeEventListener("pointerdown", outside));
</script>
<template>
<div ref="root" class="language" @keydown.esc="open = false">
<GlassButton
:aria-label="$t('general.language')"
:aria-expanded="open"
aria-controls="language-options"
@click="open = !open"
><Globe2 :size="17" /><span>{{ locale.toUpperCase() }}</span
><ChevronDown :size="14"
/></GlassButton>
<GlassCard
v-if="open"
id="language-options"
class="language-menu"
role="group"
:aria-label="$t('general.language')"
><button
v-for="lang in languages"
:key="lang.code"
:aria-pressed="locale === lang.code"
@click="
setLocale(lang.code);
open = false;
"
>
{{ lang.name }}<span v-if="locale === lang.code">✓</span>
</button></GlassCard
>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
const props = defineProps<{ stream: MediaStream | null; mirror?: boolean }>();
const video = ref<HTMLVideoElement>();
watch(
() => props.stream,
async (stream) => {
await nextTick();
if (video.value) {
video.value.srcObject = stream;
if (stream) void video.value.play().catch(() => {});
}
},
{ immediate: true },
);
</script>
<template>
<video ref="video" autoplay playsinline muted :class="{ mirrored: mirror }" />
</template>

View File

@@ -0,0 +1,7 @@
export function useApiError() {
const { t, te } = useI18n();
return (error: any) => {
const key = `errors.${error?.data?.error || error?.name || "server"}`;
return te(key) ? t(key) : t("errors.server");
};
}

View File

@@ -0,0 +1,321 @@
export function useStream(mode: "host" | "viewer") {
const media = shallowRef<MediaStream | null>(null);
const remote = shallowRef<MediaStream | null>(null);
const state = ref("offline");
const error = ref("");
const source = ref<"front" | "back" | "screen">("front");
const rearAvailable = ref(false);
const screenAvailable = ref(false);
const resolution = ref("");
const quality = ref("auto");
let socket: WebSocket | null = null;
const peers = new Map<string, RTCPeerConnection>();
const pending = new Map<string, RTCIceCandidateInit[]>();
let retry: ReturnType<typeof setTimeout> | undefined;
let stats: ReturnType<typeof setInterval> | undefined;
let reconnects = 0;
let desired = false;
let captureVersion = 0;
const apiError = useApiError();
const session = useSessionStore();
function send(type: string, target: string, data?: unknown) {
if (socket?.readyState === WebSocket.OPEN)
socket.send(JSON.stringify({ type, target, data }));
}
function clearPeers() {
for (const p of peers.values()) p.close();
peers.clear();
pending.clear();
remote.value = null;
resolution.value = "";
}
function removePeer(id: string) {
peers.get(id)?.close();
peers.delete(id);
pending.delete(id);
}
async function setQuality(value: string) {
quality.value = value;
for (const peer of peers.values())
for (const sender of peer.getSenders()) {
if (sender.track?.kind !== "video") continue;
const params = sender.getParameters();
if (!params.encodings?.length) params.encodings = [{}];
params.encodings[0]!.maxBitrate =
value === "low" ? 600_000 : value === "high" ? 4_000_000 : 2_000_000;
try {
await sender.setParameters(params);
} catch {
/* Browser may not expose encoding controls. */
}
}
}
function peer(id: string) {
const p = new RTCPeerConnection({ iceServers: [] }); // LAN-only: no external STUN/TURN services.
peers.set(id, p);
p.onicecandidate = (e) => {
if (e.candidate) send("ice", id, e.candidate.toJSON());
};
p.ontrack = (e) => {
remote.value = e.streams[0] ?? new MediaStream([e.track]);
state.value = "live";
};
p.onconnectionstatechange = () => {
if (p.connectionState === "connected") {
if (mode === "viewer") state.value = "live";
void setQuality(quality.value);
}
if (p.connectionState === "disconnected" && mode === "viewer")
state.value = "reconnecting";
if (p.connectionState === "failed") {
removePeer(id);
if (mode === "viewer") {
state.value = "reconnecting";
send("renegotiate", id);
}
}
};
if (mode === "host")
media.value
?.getTracks()
.forEach((track) => p.addTrack(track, media.value!));
return p;
}
async function offer(id: string) {
removePeer(id);
const p = peer(id);
await p.setLocalDescription(await p.createOffer());
send("offer", id, p.localDescription);
}
async function drain(id: string, p: RTCPeerConnection) {
for (const ice of pending.get(id) ?? []) await p.addIceCandidate(ice);
pending.delete(id);
}
async function message(v: any) {
if (v.type === "error") {
desired = false;
error.value = apiError({ data: { error: v.error } });
state.value = "offline";
socket?.close();
return;
}
if (v.type === "welcome") {
state.value =
mode === "host" ? "live" : v.live ? "connecting" : "waiting";
reconnects = 0;
}
if (v.type === "host-offline") {
clearPeers();
state.value = "waiting";
}
if (v.type === "host-online") state.value = "connecting";
if (v.type === "viewer-left") removePeer(v.peer);
if (
(v.type === "viewer-joined" || v.type === "renegotiate") &&
mode === "host"
)
await offer(v.peer);
if (v.type === "offer" && mode === "viewer") {
// ICE messages are serialized behind this handler.
const earlyIce = pending.get(v.peer) ?? [];
removePeer(v.peer);
pending.set(v.peer, earlyIce);
const p = peer(v.peer);
await p.setRemoteDescription(v.data);
await drain(v.peer, p);
await p.setLocalDescription(await p.createAnswer());
send("answer", v.peer, p.localDescription);
}
if (v.type === "answer") {
const p = peers.get(v.peer);
if (p) {
await p.setRemoteDescription(v.data);
await drain(v.peer, p);
}
}
if (v.type === "ice") {
const p = peers.get(v.peer);
if (p?.remoteDescription) await p.addIceCandidate(v.data);
else pending.set(v.peer, [...(pending.get(v.peer) ?? []), v.data]);
}
}
function connect() {
if (!desired) return;
state.value = reconnects ? "reconnecting" : "connecting";
const ws = new WebSocket(
`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/signal?mode=${mode}`,
);
socket = ws;
let queue = Promise.resolve();
ws.onmessage = (e) => {
queue = queue
.then(() => {
if (socket === ws && desired) return message(JSON.parse(e.data));
})
.catch((e) => {
error.value = apiError(e);
});
};
ws.onclose = async () => {
if (socket !== ws) return;
socket = null;
clearPeers();
if (!desired) return;
state.value = "reconnecting";
if (!(await session.refresh())) {
desired = false;
stop();
await navigateTo("/login");
return;
}
if (!desired) return;
retry = setTimeout(connect, Math.min(1000 * 2 ** reconnects++, 10000));
};
}
async function detectSources() {
screenAvailable.value = !!navigator.mediaDevices?.getDisplayMedia;
try {
const devices = await navigator.mediaDevices?.enumerateDevices();
rearAvailable.value =
!!devices?.some(
(d) =>
d.kind === "videoinput" && /back|rear|environment/i.test(d.label),
) || (devices?.filter((d) => d.kind === "videoinput").length ?? 0) > 1;
} catch {
rearAvailable.value = false;
}
}
async function capture(selected = source.value) {
const version = ++captureVersion;
error.value = "";
if (!window.isSecureContext || !navigator.mediaDevices) {
error.value = apiError({ data: { error: "secureContext" } });
return false;
}
let next: MediaStream | null = null;
try {
const acquire = () =>
selected === "screen"
? navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 30 },
audio: false,
})
: navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode:
selected === "back" ? { exact: "environment" } : "user",
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30, max: 30 },
},
});
try {
next = await acquire();
} catch (e: any) {
// Some mobile browsers require releasing the previous camera before opening another lens.
if (
e.name !== "NotReadableError" ||
!media.value ||
source.value === "screen" ||
selected === "screen"
)
throw e;
media.value.getTracks().forEach((t) => t.stop());
try {
next = await acquire();
} catch (retryError) {
stop();
throw retryError;
}
}
if (version !== captureVersion) {
next.getTracks().forEach((t) => t.stop());
return false;
}
const track = next.getVideoTracks()[0]!;
const renegotiate: string[] = [];
for (const [id, p] of peers) {
const sender = p.getSenders().find((s) => s.track?.kind === "video");
try {
await sender?.replaceTrack(track);
} catch {
renegotiate.push(id);
}
}
const previous = media.value;
media.value = next;
source.value = selected;
for (const id of renegotiate) await offer(id);
previous?.getTracks().forEach((t) => t.stop());
track.onended = () => {
if (media.value === next) stop();
};
await detectSources();
return true;
} catch (e) {
next?.getTracks().forEach((t) => t.stop());
error.value = apiError(e);
return false;
}
}
async function start() {
error.value = "";
if (desired) return;
if (mode === "host" && !media.value && !(await capture())) return;
desired = true;
reconnects = 0;
connect();
}
function stop() {
desired = false;
captureVersion++;
clearTimeout(retry);
const old = socket;
socket = null;
old?.close();
clearPeers();
media.value?.getTracks().forEach((t) => t.stop());
media.value = null;
state.value = "offline";
}
onMounted(() => {
void detectSources();
stats = setInterval(async () => {
if (mode !== "viewer") return;
for (const p of peers.values()) {
try {
(await p.getStats()).forEach((report) => {
if (
report.type === "inbound-rtp" &&
report.kind === "video" &&
report.frameWidth
)
resolution.value = `${report.frameWidth} × ${report.frameHeight}`;
});
} catch {
/* A closing peer has no stats. */
}
}
}, 3000);
});
onBeforeUnmount(() => {
stop();
clearInterval(stats);
});
return {
media,
remote,
state,
error,
source,
rearAvailable,
screenAvailable,
resolution,
quality,
capture,
start,
stop,
setQuality,
};
}

View File

@@ -0,0 +1,177 @@
{
"nav": {
"main": "Main navigation",
"host": "Host studio",
"watch": "Watch",
"accounts": "Accounts",
"settings": "Settings"
},
"common": {
"close": "Close",
"logout": "Sign out",
"skip": "Skip to content",
"working": "Working…",
"loading": "Loading…",
"cancel": "Cancel",
"save": "Save changes",
"refresh": "Refresh"
},
"login": {
"local": "Your network. Your space.",
"headline": "A little closer.",
"headlineAccent": "Wherever you are.",
"description": "Turn a device into a live window. Share your camera or screen, privately on your local network.",
"privacy": "No cloud. Just a connection.",
"title": "Welcome back",
"subtitle": "Sign in to this host to join the stream.",
"setupTitle": "Make yourself at home",
"setupDescription": "Create the administrator account for this host.",
"usernamePlaceholder": "Your username",
"passwordPlaceholder": "Your password",
"setupToken": "Setup token",
"tokenHint": "Use the token in the setup-token file created by the local server.",
"create": "Create host account",
"signin": "Sign in",
"hostAccounts": "Accounts belong to this host only",
"localUtility": "A local streaming utility"
},
"host": {
"eyebrow": "Your local workspace",
"title": "Host studio",
"subtitle": "A live window into your world. You’re in control.",
"start": "Go live",
"stop": "Stop hosting",
"previewTitle": "Your view starts here",
"previewHint": "Choose a source below, then go live when you’re ready.",
"enableCamera": "Enable preview",
"private": "Private stream",
"broadcasting": "Broadcasting to your network",
"previewOnly": "Only you can see this preview",
"audioOff": "Video only · No audio",
"videoSource": "Video source",
"switchAnytime": "Switch anytime",
"rearHint": "Enable the camera first to discover available lenses.",
"uptime": "Time live",
"viewers": "Viewers",
"quality": "Send quality",
"connectDevice": "Connect a device",
"scanHint": "Scan to open the stream on another device.",
"qrAlt": "QR code for the local watch address",
"localAddress": "Local address",
"copy": "Copy address",
"copied": "Address copied",
"sameNetwork": "Connect to the same Wi-Fi",
"connected": "Connected viewers",
"noViewers": "A quiet room, for now",
"inviteHint": "Share the link to invite your first viewer.",
"kick": "Disconnect",
"manageAccess": "Manage access",
"footer": "Local by design. Your stream stays on your network."
},
"sources": {
"front": "Front camera",
"back": "Back camera",
"screen": "Screen"
},
"stream": {
"offline": "Offline",
"connecting": "Connecting",
"reconnecting": "Reconnecting",
"waiting": "Waiting for host",
"live": "Live"
},
"quality": {
"auto": "Adaptive",
"high": "High · 4 Mbps",
"low": "Low · 600 Kbps"
},
"watch": {
"fullscreen": "Toggle fullscreen",
"disconnect": "Disconnect",
"reconnect": "Reconnect",
"waitingTitle": "Ready when they are",
"waitingHint": "The host isn’t live yet. The stream will appear here automatically.",
"connectingTitle": "Bringing you closer",
"connectingHint": "Connecting to the host on your local network."
},
"accounts": {
"eyebrow": "Who’s welcome",
"title": "Accounts & security",
"subtitle": "A private space, with access on your terms.",
"add": "Add viewer",
"protected": "Every connection starts with permission",
"protectedHint": "Sign-in is required. Passwords are hashed. Access can be revoked anytime.",
"localAccounts": "Local accounts",
"account": "Account",
"role": "Role",
"lastLogin": "Last sign-in",
"sessions": "Sessions",
"actions": "Actions",
"you": "You",
"admin": "Host / Admin",
"viewer": "Viewer",
"username": "Username",
"password": "Password",
"usernameHint": "3–40 characters: Latin letters, numbers, dots, underscores or hyphens.",
"passwordHint": "At least 12 characters, including a letter and a number.",
"never": "Not yet",
"reset": "Reset password",
"delete": "Delete account",
"saved": "Access settings updated.",
"activeSessions": "Active sessions",
"sessionHint": "Sessions expire after 30 minutes of inactivity or 12 hours.",
"current": "This session",
"lastActive": "Last active",
"revoke": "Revoke",
"resetHint": "All sessions for {name} will be signed out immediately.",
"deleteHint": "Delete {name}? This removes the account and immediately disconnects all its sessions."
},
"general": {
"eyebrow": "Make it yours",
"title": "Settings",
"subtitle": "Small details. Your kind of space.",
"appearance": "Appearance",
"dark": "Dark glass",
"light": "Light glass",
"accent": "Accent color",
"accentHint": "A little personality for your controls.",
"language": "Language",
"displayLanguage": "Display language",
"languageHint": "Used throughout Webka on this browser.",
"privacy": "Privacy & connection",
"loginRequired": "Require sign-in",
"loginHint": "Only accounts created on this host can watch.",
"alwaysOn": "Always on",
"localOnly": "Local network only",
"localHint": "No cloud relay, analytics, or external media services.",
"savedLocally": "Your preferences are saved in this browser."
},
"colors": {
"lime": "Lime",
"blue": "Blue",
"violet": "Violet",
"peach": "Peach"
},
"errors": {
"server": "Couldn’t complete the request. Check that the local server is running and try again.",
"unauthorized": "Your credentials are incorrect or your session has expired. Please sign in again.",
"forbidden": "This action requires a host account.",
"origin": "This address does not match the server’s configured address. Open the configured local URL.",
"setupToken": "The setup token is incorrect.",
"alreadySetup": "This host already has an administrator. Sign in instead.",
"username": "Use 3–40 Latin letters, numbers, dots, underscores or hyphens.",
"password": "Use at least 12 characters, with a letter and a number.",
"rateLimited": "Too many attempts. Wait a minute and try again.",
"duplicate": "That username is already taken.",
"protectedAccount": "The host account cannot be deleted.",
"notFound": "This account no longer exists.",
"hostBusy": "This session is already connected or another host is live. Close the other tab and try again.",
"secureContext": "Camera and screen access need HTTPS, or localhost on the server device. Open the secure host address.",
"NotAllowedError": "Access was cancelled or denied. Allow camera or screen access in your browser and try again.",
"NotFoundError": "No matching camera was found on this device.",
"NotReadableError": "The camera is busy. Close other apps using it and try again.",
"OverconstrainedError": "This camera source is unavailable. Try the front camera.",
"clipboard": "Couldn’t copy automatically. Select and copy the address above.",
"fullscreen": "Fullscreen isn’t available in this browser."
}
}

View File

@@ -0,0 +1,177 @@
{
"nav": {
"main": "Navegación principal",
"host": "Estudio",
"watch": "Ver",
"accounts": "Cuentas",
"settings": "Ajustes"
},
"common": {
"close": "Cerrar",
"logout": "Cerrar sesión",
"skip": "Ir al contenido",
"working": "Procesando…",
"loading": "Cargando…",
"cancel": "Cancelar",
"save": "Guardar cambios",
"refresh": "Actualizar"
},
"login": {
"local": "Tu red. Tu espacio.",
"headline": "Un poco más cerca.",
"headlineAccent": "Estés donde estés.",
"description": "Convierte un dispositivo en una ventana en directo. Comparte tu cámara o pantalla en tu red local.",
"privacy": "Sin nube. Solo una conexión.",
"title": "Te damos la bienvenida",
"subtitle": "Inicia sesión en este servidor para ver la transmisión.",
"setupTitle": "Este es tu espacio",
"setupDescription": "Crea la cuenta de administrador de este servidor.",
"usernamePlaceholder": "Tu nombre de usuario",
"passwordPlaceholder": "Tu contraseña",
"setupToken": "Código de configuración",
"tokenHint": "Usa el código del archivo setup-token creado por el servidor local.",
"create": "Crear cuenta",
"signin": "Iniciar sesión",
"hostAccounts": "Las cuentas pertenecen solo a este servidor",
"localUtility": "Transmisiones locales"
},
"host": {
"eyebrow": "Tu espacio local",
"title": "Estudio de emisión",
"subtitle": "Una ventana a tu mundo. Tú tienes el control.",
"start": "Emitir en directo",
"stop": "Dejar de emitir",
"previewTitle": "Tu vista empieza aquí",
"previewHint": "Elige una fuente y empieza a emitir cuando quieras.",
"enableCamera": "Activar vista previa",
"private": "Emisión privada",
"broadcasting": "Emitiendo en tu red",
"previewOnly": "Solo tú puedes ver esta vista previa",
"audioOff": "Solo vídeo · Sin audio",
"videoSource": "Fuente de vídeo",
"switchAnytime": "Cambia cuando quieras",
"rearHint": "Activa la cámara para detectar las lentes disponibles.",
"uptime": "Tiempo en directo",
"viewers": "Espectadores",
"quality": "Calidad de envío",
"connectDevice": "Conectar un dispositivo",
"scanHint": "Escanea para abrir la emisión en otro dispositivo.",
"qrAlt": "Código QR de la dirección local",
"localAddress": "Dirección local",
"copy": "Copiar dirección",
"copied": "Dirección copiada",
"sameNetwork": "Conéctate a la misma red Wi-Fi",
"connected": "Espectadores conectados",
"noViewers": "Por ahora, todo tranquilo",
"inviteHint": "Comparte el enlace para invitar a alguien.",
"kick": "Desconectar",
"manageAccess": "Gestionar acceso",
"footer": "Diseñado para tu red. Tu emisión se queda en ella."
},
"sources": {
"front": "Cámara frontal",
"back": "Cámara trasera",
"screen": "Pantalla"
},
"stream": {
"offline": "Desconectado",
"connecting": "Conectando",
"reconnecting": "Reconectando",
"waiting": "Esperando al anfitrión",
"live": "En directo"
},
"quality": {
"auto": "Adaptable",
"high": "Alta · 4 Mbps",
"low": "Baja · 600 Kbps"
},
"watch": {
"fullscreen": "Alternar pantalla completa",
"disconnect": "Desconectar",
"reconnect": "Reconectar",
"waitingTitle": "Todo listo para empezar",
"waitingHint": "El anfitrión aún no está emitiendo. El vídeo aparecerá automáticamente.",
"connectingTitle": "Conectando contigo",
"connectingHint": "Conectando al servidor de tu red local."
},
"accounts": {
"eyebrow": "Quién puede entrar",
"title": "Cuentas y seguridad",
"subtitle": "Un espacio privado, con acceso a tu manera.",
"add": "Añadir espectador",
"protected": "Cada conexión empieza con un permiso",
"protectedHint": "El inicio de sesión es obligatorio. Las contraseñas se almacenan con hash. Puedes revocar el acceso.",
"localAccounts": "Cuentas locales",
"account": "Cuenta",
"role": "Rol",
"lastLogin": "Último acceso",
"sessions": "Sesiones",
"actions": "Acciones",
"you": "Tú",
"admin": "Administrador",
"viewer": "Espectador",
"username": "Nombre de usuario",
"password": "Contraseña",
"usernameHint": "3–40 caracteres: letras latinas, números, puntos, guiones o guiones bajos.",
"passwordHint": "Al menos 12 caracteres, con una letra y un número.",
"never": "Todavía no",
"reset": "Restablecer contraseña",
"delete": "Eliminar cuenta",
"saved": "Ajustes de acceso actualizados.",
"activeSessions": "Sesiones activas",
"sessionHint": "Las sesiones caducan tras 30 minutos de inactividad o 12 horas.",
"current": "Esta sesión",
"lastActive": "Última actividad",
"revoke": "Revocar",
"resetHint": "Se cerrarán todas las sesiones de {name} de inmediato.",
"deleteHint": "¿Eliminar a {name}? Se borrará la cuenta y se desconectarán todas sus sesiones."
},
"general": {
"eyebrow": "A tu gusto",
"title": "Ajustes",
"subtitle": "Pequeños detalles para tu espacio.",
"appearance": "Apariencia",
"dark": "Cristal oscuro",
"light": "Cristal claro",
"accent": "Color de acento",
"accentHint": "Dale personalidad a los controles.",
"language": "Idioma",
"displayLanguage": "Idioma de la interfaz",
"languageHint": "Se usa en toda la aplicación en este navegador.",
"privacy": "Privacidad y conexión",
"loginRequired": "Exigir inicio de sesión",
"loginHint": "Solo las cuentas de este servidor pueden ver la emisión.",
"alwaysOn": "Siempre activo",
"localOnly": "Solo red local",
"localHint": "Sin retransmisión en la nube, analítica ni servicios multimedia externos.",
"savedLocally": "Tus preferencias se guardan en este navegador."
},
"colors": {
"lime": "Lima",
"blue": "Azul",
"violet": "Violeta",
"peach": "Melocotón"
},
"errors": {
"server": "No se pudo completar la solicitud. Comprueba que el servidor local esté en marcha.",
"unauthorized": "Las credenciales no son correctas o la sesión ha caducado. Vuelve a iniciar sesión.",
"forbidden": "Esta acción requiere una cuenta de administrador.",
"origin": "Esta dirección no coincide con la del servidor. Abre la URL local configurada.",
"setupToken": "El código de configuración no es correcto.",
"alreadySetup": "Este servidor ya tiene un administrador. Inicia sesión.",
"username": "Usa 3–40 letras latinas, números, puntos o guiones.",
"password": "Usa al menos 12 caracteres, con una letra y un número.",
"rateLimited": "Demasiados intentos. Espera un minuto.",
"duplicate": "Ese nombre de usuario ya existe.",
"protectedAccount": "No se puede eliminar la cuenta del administrador.",
"notFound": "Esta cuenta ya no existe.",
"hostBusy": "Esta sesión ya está conectada u otro anfitrión está emitiendo. Cierra la otra pestaña.",
"secureContext": "La cámara y la pantalla necesitan HTTPS o localhost en el servidor. Abre la dirección segura.",
"NotAllowedError": "Se canceló o denegó el acceso. Permite el acceso a la cámara o pantalla.",
"NotFoundError": "No se encontró una cámara compatible.",
"NotReadableError": "La cámara está ocupada. Cierra las otras aplicaciones que la usan.",
"OverconstrainedError": "Esta fuente no está disponible. Prueba la cámara frontal.",
"clipboard": "No se pudo copiar automáticamente. Selecciona y copia la dirección.",
"fullscreen": "La pantalla completa no está disponible en este navegador."
}
}

View File

@@ -0,0 +1,177 @@
{
"nav": {
"main": "Основная навигация",
"host": "Студия",
"watch": "Просмотр",
"accounts": "Аккаунты",
"settings": "Настройки"
},
"common": {
"close": "Закрыть",
"logout": "Выйти",
"skip": "К содержимому",
"working": "Подождите…",
"loading": "Загрузка…",
"cancel": "Отмена",
"save": "Сохранить",
"refresh": "Обновить"
},
"login": {
"local": "Ваша сеть. Ваше пространство.",
"headline": "Немного ближе.",
"headlineAccent": "Где бы вы ни были.",
"description": "Превратите устройство в окно прямого эфира. Делитесь камерой или экраном в своей локальной сети.",
"privacy": "Без облака. Только связь.",
"title": "С возвращением",
"subtitle": "Войдите на этот сервер для просмотра.",
"setupTitle": "Добро пожаловать домой",
"setupDescription": "Создайте аккаунт администратора этого сервера.",
"usernamePlaceholder": "Имя пользователя",
"passwordPlaceholder": "Пароль",
"setupToken": "Код первоначальной настройки",
"tokenHint": "Введите код из файла setup-token, созданного локальным сервером.",
"create": "Создать аккаунт",
"signin": "Войти",
"hostAccounts": "Аккаунты доступны только на этом сервере",
"localUtility": "Локальные трансляции"
},
"host": {
"eyebrow": "Ваше локальное пространство",
"title": "Студия трансляций",
"subtitle": "Окно в ваш мир. Всё под вашим контролем.",
"start": "Начать эфир",
"stop": "Остановить",
"previewTitle": "Здесь начинается ваш эфир",
"previewHint": "Выберите источник и начните эфир, когда будете готовы.",
"enableCamera": "Включить предпросмотр",
"private": "Приватный эфир",
"broadcasting": "Эфир в вашей сети",
"previewOnly": "Предпросмотр виден только вам",
"audioOff": "Только видео · Без звука",
"videoSource": "Источник видео",
"switchAnytime": "Меняйте в любой момент",
"rearHint": "Включите камеру, чтобы найти доступные объективы.",
"uptime": "Время эфира",
"viewers": "Зрители",
"quality": "Качество передачи",
"connectDevice": "Подключить устройство",
"scanHint": "Отсканируйте код на другом устройстве.",
"qrAlt": "QR-код адреса просмотра",
"localAddress": "Локальный адрес",
"copy": "Копировать адрес",
"copied": "Адрес скопирован",
"sameNetwork": "Подключитесь к той же Wi-Fi сети",
"connected": "Подключённые зрители",
"noViewers": "Пока здесь тихо",
"inviteHint": "Поделитесь ссылкой с первым зрителем.",
"kick": "Отключить",
"manageAccess": "Управление доступом",
"footer": "Локально по замыслу. Эфир остаётся в вашей сети."
},
"sources": {
"front": "Передняя камера",
"back": "Задняя камера",
"screen": "Экран"
},
"stream": {
"offline": "Не в эфире",
"connecting": "Подключение",
"reconnecting": "Переподключение",
"waiting": "Ожидание эфира",
"live": "В эфире"
},
"quality": {
"auto": "Автоматически",
"high": "Высокое · 4 Мбит/с",
"low": "Низкое · 600 Кбит/с"
},
"watch": {
"fullscreen": "Полноэкранный режим",
"disconnect": "Отключиться",
"reconnect": "Подключиться снова",
"waitingTitle": "Готовы к началу эфира",
"waitingHint": "Эфир ещё не начался. Видео появится здесь автоматически.",
"connectingTitle": "Устанавливаем связь",
"connectingHint": "Подключаемся к серверу в вашей локальной сети."
},
"accounts": {
"eyebrow": "Кому открыт доступ",
"title": "Аккаунты и безопасность",
"subtitle": "Личное пространство с доступом на ваших условиях.",
"add": "Добавить зрителя",
"protected": "Каждое подключение — с разрешения",
"protectedHint": "Вход обязателен. Пароли хешируются. Доступ можно отозвать в любой момент.",
"localAccounts": "Локальные аккаунты",
"account": "Аккаунт",
"role": "Роль",
"lastLogin": "Последний вход",
"sessions": "Сеансы",
"actions": "Действия",
"you": "Вы",
"admin": "Владелец",
"viewer": "Зритель",
"username": "Имя пользователя",
"password": "Пароль",
"usernameHint": "3–40 символов: латинские буквы, цифры, точки, подчёркивания или дефисы.",
"passwordHint": "Не менее 12 символов, включая букву и цифру.",
"never": "Ещё не входил",
"reset": "Сменить пароль",
"delete": "Удалить аккаунт",
"saved": "Настройки доступа обновлены.",
"activeSessions": "Активные сеансы",
"sessionHint": "Сеансы истекают через 30 минут бездействия или через 12 часов.",
"current": "Этот сеанс",
"lastActive": "Последняя активность",
"revoke": "Отозвать",
"resetHint": "Все сеансы пользователя {name} будут завершены.",
"deleteHint": "Удалить {name}? Аккаунт будет удалён, все его сеансы завершатся."
},
"general": {
"eyebrow": "На ваш вкус",
"title": "Настройки",
"subtitle": "Маленькие детали вашего пространства.",
"appearance": "Внешний вид",
"dark": "Тёмное стекло",
"light": "Светлое стекло",
"accent": "Цвет акцента",
"accentHint": "Добавьте индивидуальности элементам управления.",
"language": "Язык",
"displayLanguage": "Язык интерфейса",
"languageHint": "Используется во всём приложении в этом браузере.",
"privacy": "Приватность и подключение",
"loginRequired": "Обязательный вход",
"loginHint": "Просмотр доступен только аккаунтам этого сервера.",
"alwaysOn": "Всегда включено",
"localOnly": "Только локальная сеть",
"localHint": "Без облачных ретрансляторов, аналитики и внешних медиасервисов.",
"savedLocally": "Предпочтения сохраняются в этом браузере."
},
"colors": {
"lime": "Лайм",
"blue": "Голубой",
"violet": "Фиолетовый",
"peach": "Персиковый"
},
"errors": {
"server": "Не удалось выполнить запрос. Проверьте, что локальный сервер запущен.",
"unauthorized": "Неверные данные для входа или сеанс истёк. Войдите снова.",
"forbidden": "Для этого действия нужен аккаунт владельца.",
"origin": "Адрес не совпадает с настройкой сервера. Откройте настроенный локальный URL.",
"setupToken": "Неверный код первоначальной настройки.",
"alreadySetup": "Администратор уже создан. Войдите в аккаунт.",
"username": "Используйте 3–40 латинских букв, цифр, точек, подчёркиваний или дефисов.",
"password": "Используйте не менее 12 символов, включая букву и цифру.",
"rateLimited": "Слишком много попыток. Подождите минуту.",
"duplicate": "Это имя пользователя уже занято.",
"protectedAccount": "Аккаунт владельца нельзя удалить.",
"notFound": "Этот аккаунт больше не существует.",
"hostBusy": "Этот сеанс уже подключён или другой владелец ведёт эфир. Закройте другую вкладку.",
"secureContext": "Для доступа к камере и экрану нужен HTTPS или localhost на устройстве сервера. Откройте защищённый адрес.",
"NotAllowedError": "Доступ отменён или запрещён. Разрешите камеру или экран в браузере.",
"NotFoundError": "На устройстве не найдена подходящая камера.",
"NotReadableError": "Камера занята. Закройте другие использующие её приложения.",
"OverconstrainedError": "Этот источник недоступен. Попробуйте переднюю камеру.",
"clipboard": "Не удалось скопировать автоматически. Выделите и скопируйте адрес выше.",
"fullscreen": "Полноэкранный режим недоступен в этом браузере."
}
}

View File

@@ -0,0 +1,12 @@
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === "/login") return;
const session = useSessionStore();
if (!(await session.refresh())) return navigateTo("/login");
if (
(to.path === "/host" || to.path.startsWith("/settings/accounts")) &&
session.user?.role !== "admin"
)
return navigateTo("/watch");
if (to.path === "/")
return navigateTo(session.user?.role === "admin" ? "/host" : "/watch");
});

52
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,52 @@
export default defineNuxtConfig({
compatibilityDate: "2025-07-01",
ssr: false,
devtools: { enabled: false },
modules: ["@pinia/nuxt", "@nuxtjs/i18n"],
css: ["~/assets/css/main.css"],
app: {
head: {
title: "Webka",
meta: [
{
name: "viewport",
content: "width=device-width, initial-scale=1, viewport-fit=cover",
},
{ name: "theme-color", content: "#101217" },
],
},
},
i18n: {
strategy: "no_prefix",
defaultLocale: "en",
locales: [
{ code: "en", name: "English", file: "en.json" },
{ code: "ru", name: "Русский", file: "ru.json" },
{ code: "es", name: "Español", file: "es.json" },
],
detectBrowserLanguage: {
useCookie: true,
cookieKey: "webka_locale",
fallbackLocale: "en",
redirectOn: "root",
},
},
nitro: {
devProxy: {
"/api": {
target: "http://127.0.0.1:3001/api",
ws: true,
changeOrigin: false,
},
},
prerender: {
routes: [
"/login",
"/host",
"/watch",
"/settings/accounts",
"/settings/general",
],
},
},
});

13592
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "webka-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev --host 127.0.0.1",
"build": "nuxt generate",
"typecheck": "nuxt typecheck",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxtjs/i18n": "10.1.1",
"@pinia/nuxt": "0.11.3",
"lucide-vue-next": "^0.468.0",
"nuxt": "3.21.11",
"pinia": "^3.0.0",
"qrcode": "^1.5.4",
"vue": "^3.5.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.5",
"typescript": "^5.9.0",
"vue-tsc": "^3.1.0"
},
"engines": {
"node": ">=22.12.0"
}
}

315
frontend/pages/host.vue Normal file
View 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
View File

@@ -0,0 +1 @@
<template><div /></template>

140
frontend/pages/login.vue Normal file
View 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>

View 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>

View 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
View 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>

View File

@@ -0,0 +1,37 @@
import { readFileSync, readdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { join } from "node:path";
const root = fileURLToPath(new URL("../", import.meta.url));
const flatten = (value, prefix = "") =>
Object.entries(value).flatMap(([key, v]) =>
typeof v === "object"
? flatten(v, `${prefix}${key}.`)
: [`${prefix}${key}`],
);
const locales = ["en", "ru", "es"].map((code) =>
JSON.parse(readFileSync(join(root, `i18n/locales/${code}.json`), "utf8")),
);
const expected = flatten(locales[0]).sort();
for (let i = 1; i < locales.length; i++)
if (JSON.stringify(flatten(locales[i]).sort()) !== JSON.stringify(expected))
throw Error("Locale key mismatch");
const files = (path) =>
readdirSync(path, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory()
? files(join(path, entry.name))
: [join(path, entry.name)],
);
for (const file of [
...files(join(root, "pages")),
...files(join(root, "components")),
join(root, "app.vue"),
]) {
for (const match of readFileSync(file, "utf8").matchAll(
/\b(?:\$t|t)\(['"]([\w.]+)['"]/g,
))
if (!expected.includes(match[1]))
throw Error(`${file}: Missing ${match[1]}`);
}
console.log(
`${expected.length} keys match in en/ru/es; static template keys verified.`,
);

View File

@@ -0,0 +1,31 @@
import { defineStore } from "pinia";
export interface User {
id: string;
username: string;
role: "admin" | "viewer";
session_id: string;
}
export const useSessionStore = defineStore("session", () => {
const user = ref<User | null>(null);
const ready = ref(false);
async function refresh() {
try {
user.value = await $fetch<User>("/api/session");
return true;
} catch {
user.value = null;
return false;
} finally {
ready.value = true;
}
}
async function logout() {
try {
await $fetch("/api/logout", { method: "POST" });
} finally {
user.value = null;
await navigateTo("/login");
}
}
return { user, ready, refresh, logout };
});

1
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1 @@
{ "extends": "./.nuxt/tsconfig.json" }