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

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>