DUNGEONS-34: implement Webka local streaming app
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
53
frontend/app.vue
Normal file
53
frontend/app.vue
Normal 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
1706
frontend/assets/css/main.css
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/components/GlassButton.vue
Normal file
31
frontend/components/GlassButton.vue
Normal 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>
|
||||
15
frontend/components/GlassCard.vue
Normal file
15
frontend/components/GlassCard.vue
Normal 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>
|
||||
42
frontend/components/GlassModal.vue
Normal file
42
frontend/components/GlassModal.vue
Normal 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>
|
||||
62
frontend/components/GlassNavBar.vue
Normal file
62
frontend/components/GlassNavBar.vue
Normal 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>
|
||||
46
frontend/components/LanguageSwitcher.vue
Normal file
46
frontend/components/LanguageSwitcher.vue
Normal 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>
|
||||
18
frontend/components/StreamVideo.vue
Normal file
18
frontend/components/StreamVideo.vue
Normal 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>
|
||||
7
frontend/composables/useApiError.ts
Normal file
7
frontend/composables/useApiError.ts
Normal 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");
|
||||
};
|
||||
}
|
||||
321
frontend/composables/useStream.ts
Normal file
321
frontend/composables/useStream.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
177
frontend/i18n/locales/en.json
Normal file
177
frontend/i18n/locales/en.json
Normal 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."
|
||||
}
|
||||
}
|
||||
177
frontend/i18n/locales/es.json
Normal file
177
frontend/i18n/locales/es.json
Normal 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."
|
||||
}
|
||||
}
|
||||
177
frontend/i18n/locales/ru.json
Normal file
177
frontend/i18n/locales/ru.json
Normal 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": "Полноэкранный режим недоступен в этом браузере."
|
||||
}
|
||||
}
|
||||
12
frontend/middleware/auth.global.ts
Normal file
12
frontend/middleware/auth.global.ts
Normal 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
52
frontend/nuxt.config.ts
Normal 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
13592
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal 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
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>
|
||||
37
frontend/scripts/check-locales.mjs
Normal file
37
frontend/scripts/check-locales.mjs
Normal 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.`,
|
||||
);
|
||||
31
frontend/stores/session.ts
Normal file
31
frontend/stores/session.ts
Normal 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
1
frontend/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "extends": "./.nuxt/tsconfig.json" }
|
||||
Reference in New Issue
Block a user