DUNGEONS-34: implement Webka local streaming app
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user