Merge pull request #2 from SrIzan10/feat/clerk

clerk auth and presets and other goodies
This commit is contained in:
2026-01-12 23:10:21 +01:00
committed by GitHub
99 changed files with 7455 additions and 1205 deletions

View File

@@ -1,3 +1,4 @@
# helium
effortless webrtc screensharing

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "@clerk/themes/shadcn.css";
@custom-variant dark (&:is(.dark *));
@@ -43,74 +44,59 @@
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--radius: 0.5rem;
--background: oklch(0.959 0.006 264.533);
--foreground: oklch(0.431 0.043 279.511);
--muted: oklch(0.92 0.006 264.531);
--muted-foreground: oklch(0.406 0.022 264.291);
--popover: oklch(0.934 0.009 264.519);
--popover-foreground: oklch(0.345 0.032 279.621);
--card: oklch(0.942 0.008 264.524);
--card-foreground: oklch(0.389 0.037 279.559);
--border: oklch(0.92 0.006 264.529);
--input: oklch(0.895 0.008 264.52);
--primary: oklch(0.554 0.25 296.956);
--primary-foreground: oklch(1 0 180);
--secondary: oklch(0.771 0.057 304.762);
--secondary-foreground: oklch(0.245 0.044 303.216);
--accent: oklch(0.832 0.023 264.439);
--accent-foreground: oklch(0.305 0.03 263.965);
--destructive: oklch(0.484 0.188 29.368);
--destructive-foreground: oklch(0.969 0.014 21.071);
--ring: oklch(0.554 0.25 296.956);
--chart-1: oklch(0.554 0.25 296.956);
--chart-2: oklch(0.771 0.057 304.762);
--chart-3: oklch(0.832 0.023 264.439);
--chart-4: oklch(0.799 0.049 304.917);
--chart-5: oklch(0.553 0.256 296.488);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
--radius: 0.5rem;
--background: oklch(0.244 0.03 283.913);
--foreground: oklch(0.878 0.043 272.094);
--muted: oklch(0.293 0.021 285.027);
--muted-foreground: oklch(0.733 0.027 285.71);
--popover: oklch(0.216 0.025 284.103);
--popover-foreground: oklch(0.98 0.007 272.584);
--card: oklch(0.225 0.027 284.034);
--card-foreground: oklch(0.929 0.024 272.369);
--border: oklch(0.303 0.02 285.14);
--input: oklch(0.331 0.023 285.098);
--primary: oklch(0.787 0.119 304.446);
--primary-foreground: oklch(0.277 0.139 295.596);
--secondary: oklch(0.334 0.068 303.657);
--secondary-foreground: oklch(0.864 0.033 305.939);
--accent: oklch(0.372 0.055 283.423);
--accent-foreground: oklch(0.91 0.014 286.109);
--destructive: oklch(75.56% 0.13 2.76);
--destructive-foreground: oklch(1 0 180);
--ring: oklch(0.787 0.119 304.446);
--chart-1: oklch(0.787 0.119 304.446);
--chart-2: oklch(0.334 0.068 303.657);
--chart-3: oklch(0.372 0.055 283.423);
--chart-4: oklch(0.359 0.075 303.591);
--chart-5: oklch(0.784 0.123 304.365);
}
@layer base {
@@ -124,3 +110,7 @@
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
}
}
.cl-avatarBox {
@apply size-8;
}

View File

@@ -1,20 +1,46 @@
<script setup lang="ts">
import { ref } from "vue"
import { ref, watch } from "vue"
import { Delete } from "lucide-vue-next"
import {
PinInput,
PinInputGroup,
PinInputSlot,
} from "@/components/ui/pin-input"
import { Button } from "@/components/ui/button"
import { useViewerStore } from "~/state/viewer";
const viewerStore = useViewerStore()
const digits = ref<string[]>([])
// Sync local digits with store code
watch(digits, (newDigits) => {
const code = newDigits.join('')
viewerStore.code = code
})
// Also sync if store updates from elsewhere (though unlikely in this flow)
watch(() => viewerStore.code, (newCode) => {
if (newCode !== digits.value.join('')) {
digits.value = newCode.split('')
}
})
const handleNumPad = (num: number) => {
if (digits.value.length < 6) {
digits.value = [...digits.value, num.toString()]
}
}
const handleBackspace = () => {
digits.value = digits.value.slice(0, -1)
}
</script>
<template>
<div>
<div class="flex flex-col items-center gap-6">
<PinInput
id="pin-input"
@complete="(viewerStore.code = $event.join(''))"
v-model="digits"
type="number"
>
<PinInputGroup>
@@ -22,8 +48,41 @@ const viewerStore = useViewerStore()
v-for="(id, index) in 6"
:key="id"
:index="index"
class="h-14 w-10 sm:h-16 sm:w-12 text-lg sm:text-xl"
/>
</PinInputGroup>
</PinInput>
<!-- Touchscreen Numpad -->
<div class="grid grid-cols-3 gap-3 w-full max-w-[280px]">
<Button
v-for="n in 9"
:key="n"
variant="outline"
class="h-14 text-xl font-medium active:scale-95 transition-transform"
@click="handleNumPad(n)"
>
{{ n }}
</Button>
<div class="col-span-1"></div> <!-- Spacer -->
<Button
variant="outline"
class="h-14 text-xl font-medium active:scale-95 transition-transform"
@click="handleNumPad(0)"
>
0
</Button>
<Button
variant="ghost"
class="h-14 active:scale-95 transition-transform hover:bg-destructive/10 hover:text-destructive"
@click="handleBackspace"
:disabled="digits.length === 0"
>
<Delete class="w-6 h-6" />
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<template>
<Dialog :open="open" @update:open="$emit('update:open', $event)">
<DialogContent class="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Preset</DialogTitle>
<DialogDescription>
Make changes to your preset here. Click save when you're done.
</DialogDescription>
</DialogHeader>
<PresetForm
v-if="open"
:initial-values="initialValues"
:preset-id="presetUser.preset.id"
:is-edit="true"
@success="onSuccess"
/>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import PresetForm from "~/components/app/PresetForm.vue";
const props = defineProps<{
open: boolean;
presetUser: any;
}>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "refresh"): void;
}>();
const initialValues = computed(() => ({
name: props.presetUser.preset.name,
iceServers:
typeof props.presetUser.preset.iceServers === "string"
? props.presetUser.preset.iceServers
: JSON.stringify(props.presetUser.preset.iceServers, 2, 2),
default: props.presetUser.isDefault,
}));
function onSuccess() {
emit("update:open", false);
emit("refresh");
}
</script>

View File

@@ -0,0 +1,172 @@
<template>
<div class="flex flex-col">
<form :id="formId" @submit.prevent="form.handleSubmit" class="space-y-6">
<FieldGroup>
<form.Field v-slot="{ field }" name="name">
<Field :data-invalid="isInvalid(field)">
<FieldLabel :for="`${formId}-name`"> Preset name </FieldLabel>
<Input
:id="`${formId}-name`"
:name="field.name"
:model-value="field.state.value"
:aria-invalid="isInvalid(field)"
placeholder="My ICE Preset"
autocomplete="off"
@blur="field.handleBlur"
@input="field.handleChange($event.target.value)"
/>
<FieldError
v-if="isInvalid(field)"
:errors="field.state.meta.errors"
/>
</Field>
</form.Field>
</FieldGroup>
<form.Field v-slot="{ field }" name="iceServers">
<Field :data-invalid="isInvalid(field)">
<FieldLabel>Ice Servers (JSON)</FieldLabel>
<div
class="h-96 w-full border rounded-md overflow-hidden focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] transition"
>
<MonacoEditor
:model-value="field.state.value"
:options="editorOptions"
class="h-full w-full"
lang="json"
@update:model-value="field.handleChange"
@blur="field.handleBlur"
/>
</div>
<FieldError
v-if="isInvalid(field)"
:errors="field.state.meta.errors"
/>
</Field>
</form.Field>
<form.Field v-slot="{ field }" name="default">
<Field orientation="horizontal">
<FieldContent>
<FieldLabel :for="`${formId}-default`">
Set as default preset
</FieldLabel>
<FieldDescription>
This preset will be selected by default on the preset selector.
</FieldDescription>
</FieldContent>
<Switch
:id="`${formId}-default`"
:model-value="field.state.value"
@update:model-value="field.handleChange"
@blur="field.handleBlur"
/>
</Field>
</form.Field>
<Field orientation="horizontal">
<Button type="submit" :form="formId">Save</Button>
</Field>
</form>
</div>
</template>
<script setup lang="ts">
import { useForm } from "@tanstack/vue-form";
import { toast } from "vue-sonner";
import { Button } from "@/components/ui/button";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "~/components/ui/field";
import { Input } from "~/components/ui/input";
import { Switch } from "~/components/ui/switch";
import { schema } from "~/lib/schema/new-preset";
const props = defineProps<{
initialValues?: {
name: string;
iceServers: string;
default: boolean;
};
presetId?: string;
isEdit?: boolean;
}>();
const emit = defineEmits<{
(e: "success"): void;
}>();
const formId = computed(() =>
props.isEdit ? `form-edit-preset-${props.presetId}` : "form-new-preset",
);
const editorOptions = {
automaticLayout: true,
fontFamily: "'JetBrains Mono', monospace",
fontSize: 14,
minimap: { enabled: false },
theme: "catppuccin-mocha",
};
if (import.meta.client) {
const monaco = await useMonaco();
if (monaco) {
const mocha = await $fetch("/catppuccin-mocha.json");
monaco.editor.defineTheme("catppuccin-mocha", mocha);
monaco.editor.setTheme("catppuccin-mocha");
}
}
const form = useForm({
defaultValues: props.initialValues || {
name: "",
iceServers:
'[\n\t{ "urls": "stun:stun.l.google.com:19302" }\,\n\t{ "urls": "stun:stun1.l.google.com:19302" }\n]',
default: false,
},
validators: {
onSubmit: schema,
},
onSubmit: async ({ value }) => {
// Parse the JSON string back to an object for submission
const parsedValue = {
...value,
iceServers: JSON.parse(value.iceServers),
};
let url = "/api/presets/create";
let method = "POST";
if (props.isEdit && props.presetId) {
url = `/api/presets/${props.presetId}`;
method = "PUT";
}
try {
const request = await $fetch<{ success: boolean; message: string }>(url, {
method: method as any,
body: JSON.stringify(parsedValue),
});
if (request.success) {
toast.success(
props.isEdit
? "Preset updated successfully!"
: "Preset created successfully!",
);
emit("success");
}
} catch (e) {
toast.error(
props.isEdit ? "Failed to update preset." : "Failed to create preset.",
);
}
},
});
function isInvalid(field: any) {
return field.state.meta.isTouched && !field.state.meta.isValid;
}
</script>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus } from "lucide-vue-next";
import type { ApiResponse, PresetUser } from "~/lib/types/PresetGetResponse";
import { useStreamerStore } from "~/state/streamer";
const router = useRouter();
const selectedValue = ref("");
const presets = ref<PresetUser[]>([]);
const loading = ref(true);
const streamerStore = useStreamerStore();
onMounted(async () => {
try {
const response = await $fetch<ApiResponse>("/api/presets");
if (response.success) {
presets.value = response.data;
const defaultPreset = presets.value.find((p) => p.isDefault);
if (defaultPreset) {
selectedValue.value = defaultPreset.presetId;
// Load the default preset's ice servers
loadPresetIceServers(defaultPreset.presetId);
}
}
} catch (error) {
console.error("Failed to fetch presets:", error);
} finally {
loading.value = false;
}
});
async function loadPresetIceServers(presetId: string) {
try {
const response = await $fetch(`/api/presets/${presetId}`);
const preset = response?.data || response;
if (preset && preset.iceServers) {
// Parse ice servers if it's a string
let iceServers = preset.iceServers;
if (typeof iceServers === "string") {
iceServers = JSON.parse(iceServers);
}
// Set the ice servers on the streamer store
streamerStore.setIceServers(iceServers);
}
} catch (error) {
console.error("Failed to load preset ice servers:", error);
}
}
watch(selectedValue, (newValue) => {
if (newValue === "create-new") {
router.push("/presets/new");
selectedValue.value = "";
} else if (newValue) {
// Load ice servers for the selected preset
loadPresetIceServers(newValue);
}
});
</script>
<template>
<Select v-model="selectedValue" :disabled="loading">
<SelectTrigger class="w-[180px]">
<SelectValue
:placeholder="loading ? 'Loading presets...' : 'Select a preset'"
/>
</SelectTrigger>
<SelectContent>
<div
v-if="presets.length === 0 && !loading"
class="px-2 py-1.5 text-sm text-muted-foreground"
>
No presets available
</div>
<div v-else-if="!loading">
<div v-for="preset in presets" :key="preset.presetId">
<SelectItem :value="preset.presetId">
<span :class="{ 'font-semibold': preset.isDefault }">
{{ preset.preset.name }}
</span>
<span
v-if="preset.isDefault"
class="ml-2 text-xs text-muted-foreground"
>(default)</span
>
</SelectItem>
</div>
<SelectSeparator />
</div>
<SelectItem value="create-new">
<div class="font-bold flex gap-2 items-center">
<Plus class="size-4" />
Create New Preset
</div>
</SelectItem>
</SelectContent>
</Select>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { LogIn } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
</script>
<template>
<NuxtLink to="/sign-in">
<Button size="icon">
<LogIn />
</Button>
</NuxtLink>
</template>

View File

@@ -7,12 +7,14 @@ const colorMode = useColorMode()
</script>
<template>
<div class="fixed top-4 right-4 z-50">
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline">
<Icon icon="radix-icons:moon" class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Icon icon="radix-icons:sun" class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<Button size="icon" variant="outline">
<Icon icon="radix-icons:moon"
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Icon icon="radix-icons:sun"
class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-6', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>

View File

@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import DialogOverlay from "./DialogOverlay.vue"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
>
<slot />
</DialogOverlay>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
>
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,10 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { FieldVariants } from "."
import { cn } from "@/lib/utils"
import { fieldVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
orientation?: FieldVariants["orientation"]
}>()
</script>
<template>
<div
role="group"
data-slot="field"
:data-orientation="orientation"
:class="cn(
fieldVariants({ orientation }),
props.class,
)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="field-content"
:class="cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
props.class,
)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p
data-slot="field-description"
:class="cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
props.class,
)"
>
<slot />
</p>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
errors?: Array<string | { message: string | undefined } | undefined>
}>()
const content = computed(() => {
if (!props.errors || props.errors.length === 0)
return null
const uniqueErrors = [
...new Map(
props.errors
.filter(Boolean)
.map((error) => {
const message = typeof error === "string" ? error : error?.message
return [message, error]
}),
).values(),
]
if (uniqueErrors.length === 1 && uniqueErrors[0]) {
return typeof uniqueErrors[0] === "string" ? uniqueErrors[0] : uniqueErrors[0].message
}
return uniqueErrors.map(error => typeof error === "string" ? error : error?.message)
})
</script>
<template>
<div
v-if="$slots.default || content"
role="alert"
data-slot="field-error"
:class="cn('text-destructive text-sm font-normal', props.class)"
>
<slot v-if="$slots.default" />
<template v-else-if="typeof content === 'string'">
{{ content }}
</template>
<ul v-else-if="Array.isArray(content)" class="ml-4 flex list-disc flex-col gap-1">
<li v-for="(error, index) in content" :key="index">
{{ error }}
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="field-group"
:class="cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
props.class,
)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { Label } from '@/components/ui/label'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<Label
data-slot="field-label"
:class="cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
props.class,
)"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
variant?: "legend" | "label"
}>()
</script>
<template>
<legend
data-slot="field-legend"
:data-variant="variant"
:class="cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
props.class,
)"
>
<slot />
</legend>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { Separator } from '@/components/ui/separator'
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="field-separator"
:data-content="!!$slots.default"
:class="cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
props.class,
)"
>
<Separator class="absolute inset-0 top-1/2" />
<span
v-if="$slots.default"
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
<slot />
</span>
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<fieldset
data-slot="field-set"
:class="cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
props.class,
)"
>
<slot />
</fieldset>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="field-label"
:class="cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
props.class,
)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,39 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
},
)
export type FieldVariants = VariantProps<typeof fieldVariants>
export { default as Field } from "./Field.vue"
export { default as FieldContent } from "./FieldContent.vue"
export { default as FieldDescription } from "./FieldDescription.vue"
export { default as FieldError } from "./FieldError.vue"
export { default as FieldGroup } from "./FieldGroup.vue"
export { default as FieldLabel } from "./FieldLabel.vue"
export { default as FieldLegend } from "./FieldLegend.vue"
export { default as FieldSeparator } from "./FieldSeparator.vue"
export { default as FieldSet } from "./FieldSet.vue"
export { default as FieldTitle } from "./FieldTitle.vue"

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)"
>
</template>

View File

@@ -0,0 +1 @@
export { default as Input } from "./Input.vue"

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { LabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Label } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1 @@
export { default as Label } from "./Label.vue"

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot
v-slot="slotProps"
data-slot="select"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</SelectRoot>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { SelectScrollDownButton, SelectScrollUpButton } from "."
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectGroupProps } from "reka-ui"
import { SelectGroup } from "reka-ui"
const props = defineProps<SelectGroupProps>()
</script>
<template>
<SelectGroup
data-slot="select-group"
v-bind="props"
>
<slot />
</SelectGroup>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class,
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectItemTextProps } from "reka-ui"
import { SelectItemText } from "reka-ui"
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText
data-slot="select-item-text"
v-bind="props"
>
<slot />
</SelectItemText>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { SelectLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
>
<slot />
</SelectLabel>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronDown class="size-4" />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronUp } from "lucide-vue-next"
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronUp class="size-4" />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectSeparator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { SelectTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
{ size: "default" },
)
const delegatedProps = reactiveOmit(props, "class", "size")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="cn(
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectValueProps } from "reka-ui"
import { SelectValue } from "reka-ui"
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue
data-slot="select-value"
v-bind="props"
>
<slot />
</SelectValue>
</template>

View File

@@ -0,0 +1,11 @@
export { default as Select } from "./Select.vue"
export { default as SelectContent } from "./SelectContent.vue"
export { default as SelectGroup } from "./SelectGroup.vue"
export { default as SelectItem } from "./SelectItem.vue"
export { default as SelectItemText } from "./SelectItemText.vue"
export { default as SelectLabel } from "./SelectLabel.vue"
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
export { default as SelectSeparator } from "./SelectSeparator.vue"
export { default as SelectTrigger } from "./SelectTrigger.vue"
export { default as SelectValue } from "./SelectValue.vue"

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { SeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Separator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<
SeparatorProps & { class?: HTMLAttributes["class"] }
>(), {
orientation: "horizontal",
decorative: true,
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Separator
data-slot="separator"
v-bind="delegatedProps"
:class="
cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Separator } from "./Separator.vue"

View File

@@ -1,19 +1,42 @@
<script lang="ts" setup>
import type { ToasterProps } from "vue-sonner"
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-vue-next"
import { Toaster as Sonner } from "vue-sonner"
import { cn } from "@/lib/utils"
const props = defineProps<ToasterProps>()
</script>
<template>
<Sonner
class="toaster group"
v-bind="props"
:class="cn('toaster group', props.class)"
:style="{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
}"
/>
v-bind="props"
>
<template #success-icon>
<CircleCheckIcon class="size-4" />
</template>
<template #info-icon>
<InfoIcon class="size-4" />
</template>
<template #warning-icon>
<TriangleAlertIcon class="size-4" />
</template>
<template #error-icon>
<OctagonXIcon class="size-4" />
</template>
<template #loading-icon>
<div>
<Loader2Icon class="size-4 animate-spin" />
</div>
</template>
<template #close-icon>
<XIcon class="size-4" />
</template>
</Sonner>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { Loader2Icon } from "lucide-vue-next"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<Loader2Icon
role="status"
aria-label="Loading"
:class="cn('size-4 animate-spin', props.class)"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Spinner } from "./Spinner.vue"

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SwitchRoot,
SwitchThumb,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SwitchRoot
v-slot="slotProps"
data-slot="switch"
v-bind="forwarded"
:class="cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
<SwitchThumb
data-slot="switch-thumb"
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
>
<slot name="thumb" v-bind="slotProps" />
</SwitchThumb>
</SwitchRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Switch } from "./Switch.vue"

View File

@@ -1,11 +1,58 @@
<script setup lang="ts">
import ThemeDropdown from '~/components/ui/ThemeDropdown.vue';
import SignInDialog from "~/components/app/SignInDialog.vue";
import ThemeDropdown from "~/components/ui/ThemeDropdown.vue";
import "vue-sonner/style.css";
import { Toaster } from "@/components/ui/sonner";
</script>
<template>
<div>
<header class="flex justify-between items-center p-4">
<div class="flex items-center space-x-6">
<NuxtLink to="/" class="text-xl font-semibold hover:opacity-80 transition-opacity">
helium
</NuxtLink>
<nav class="flex space-x-4">
<NuxtLink
to="/"
class="text-sm font-medium hover:text-primary transition-colors"
active-class="text-primary"
>
Home
</NuxtLink>
<NuxtLink
to="/stream"
class="text-sm font-medium hover:text-primary transition-colors"
active-class="text-primary"
>
Stream
</NuxtLink>
<ClientOnly>
<SignedIn>
<NuxtLink
to="/presets"
class="text-sm font-medium hover:text-primary transition-colors"
active-class="text-primary"
>
Presets
</NuxtLink>
</SignedIn>
</ClientOnly>
</nav>
</div>
<div class="flex space-x-4">
<ThemeDropdown />
<ClientOnly>
<SignedOut>
<SignInDialog />
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</ClientOnly>
</div>
</header>
<slot />
<Toaster />
</div>
</template>

View File

@@ -1,5 +1,4 @@
import { drizzle } from 'drizzle-orm/neon-http';
import * as schema from './schema';
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
export const db = drizzle(process.env.DATABASE_URL!, { schema });

19
app/lib/db/migrate.ts Normal file
View File

@@ -0,0 +1,19 @@
import { migrate } from "drizzle-orm/neon-http/migrator";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
export async function runMigrations() {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL environment variable is not set");
}
try {
const db = drizzle(process.env.DATABASE_URL, { schema });
console.log("[DB] Running migrations...");
await migrate(db, { migrationsFolder: "./drizzle" });
console.log("[DB] Migrations completed successfully");
} catch (error) {
console.error("[DB] Migration failed:", error);
throw error;
}
}

View File

@@ -1,23 +1,97 @@
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import {
boolean,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const peers = pgTable('peers', {
id: text('id').primaryKey(),
lastSeen: timestamp('last_seen').notNull().defaultNow(),
export const peers = pgTable("peers", {
id: text("id").primaryKey(),
lastSeen: timestamp("last_seen").notNull().defaultNow(),
});
export const rooms = pgTable('rooms', {
id: text('id').primaryKey(),
broadcaster: text('broadcaster').notNull().references(() => peers.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const roomViewers = pgTable('room_viewers', {
id: uuid('id').primaryKey().defaultRandom(),
roomId: text('room_id')
export const rooms = pgTable("rooms", {
id: text("id").primaryKey(),
broadcaster: text("broadcaster")
.notNull()
.references(() => rooms.id, { onDelete: 'cascade' }),
viewerId: text('viewer_id')
.notNull()
.references(() => peers.id, { onDelete: 'cascade' }),
joinedAt: timestamp('joined_at').notNull().defaultNow(),
.references(() => peers.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const roomViewers = pgTable("room_viewers", {
id: uuid("id").primaryKey().defaultRandom(),
roomId: text("room_id")
.notNull()
.references(() => rooms.id, { onDelete: "cascade" }),
viewerId: text("viewer_id")
.notNull()
.references(() => peers.id, { onDelete: "cascade" }),
joinedAt: timestamp("joined_at").notNull().defaultNow(),
});
export const presets = pgTable("presets", {
id: uuid("id").primaryKey().defaultRandom(),
createdBy: text("created_by").notNull(),
name: text("name").notNull().unique(),
iceServers: text("ice_servers").notNull(), // stringified json obv
shareable: boolean("shareable").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const presetUsers = pgTable(
"preset_users",
{
id: uuid("id").primaryKey().defaultRandom(),
presetId: uuid("preset_id")
.notNull()
.references(() => presets.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
isDefault: boolean("is_default").notNull().default(false),
addedAt: timestamp("added_at").notNull().defaultNow(),
},
(table) => ({
uniquePresetUser: uniqueIndex().on(table.presetId, table.userId),
}),
);
// relations
export const peersRelations = relations(peers, ({ many }) => ({
roomsAsbroadcaster: many(rooms, { relationName: "broadcaster" }),
roomViewersAsViewer: many(roomViewers, { relationName: "viewer" }),
}));
export const roomsRelations = relations(rooms, ({ one, many }) => ({
broadcasterPeer: one(peers, {
fields: [rooms.broadcaster],
references: [peers.id],
relationName: "broadcaster",
}),
viewers: many(roomViewers, { relationName: "room" }),
}));
export const roomViewersRelations = relations(roomViewers, ({ one }) => ({
room: one(rooms, {
fields: [roomViewers.roomId],
references: [rooms.id],
relationName: "room",
}),
viewer: one(peers, {
fields: [roomViewers.viewerId],
references: [peers.id],
relationName: "viewer",
}),
}));
export const presetsRelations = relations(presets, ({ many }) => ({
presetUsers: many(presetUsers),
}));
export const presetUsersRelations = relations(presetUsers, ({ one }) => ({
preset: one(presets, {
fields: [presetUsers.presetId],
references: [presets.id],
}),
}));

View File

@@ -0,0 +1,108 @@
import { z } from "zod";
export const schema = z.object({
name: z
.string()
.min(3, "Name must be at least 3 characters.")
.max(20, "Name must be at most 20 characters."),
iceServers: z.string().superRefine((val, ctx) => {
// below code is ai generated. i am not writing validation myself istg
try {
const parsed = JSON.parse(val);
if (!Array.isArray(parsed)) {
ctx.addIssue({
code: "custom",
message: "Must be a JSON array",
});
return;
}
// Validate each ICE server object
parsed.forEach((item, index) => {
if (typeof item !== "object" || item === null) {
ctx.addIssue({
code: "custom",
message: `Item ${index}: must be an object`,
});
return;
}
// Validate urls field - can be string or array of strings
const { urls } = item;
if (!urls) {
ctx.addIssue({
code: "custom",
message: `Item ${index}: 'urls' is required`,
});
return;
}
const urlsList = Array.isArray(urls) ? urls : [urls];
if (!Array.isArray(urls) && typeof urls !== "string") {
ctx.addIssue({
code: "custom",
message: `Item ${index}: 'urls' must be a string or array of strings`,
});
return;
}
// Validate each URL in the urls list
urlsList.forEach((url, urlIndex) => {
if (typeof url !== "string") {
ctx.addIssue({
code: "custom",
message: `Item ${index}: urls[${urlIndex}] must be a string`,
});
return;
}
// Validate STUN/TURN URL format (RFC 8829)
const isValidStunUrl = /^stuns?:.+/.test(url);
const isValidTurnUrl = /^turns?:.+/.test(url);
if (!isValidStunUrl && !isValidTurnUrl) {
ctx.addIssue({
code: "custom",
message: `Item ${index}: urls[${urlIndex}] must be a valid STUN (stun:) or TURN (turn:/turns:) URL`,
});
}
});
// Validate optional fields
if (item.username !== undefined && typeof item.username !== "string") {
ctx.addIssue({
code: "custom",
message: `Item ${index}: 'username' must be a string`,
});
}
if (
item.credential !== undefined &&
typeof item.credential !== "string"
) {
ctx.addIssue({
code: "custom",
message: `Item ${index}: 'credential' must be a string`,
});
}
if (
item.credentialType !== undefined &&
!["password", "oauth"].includes(item.credentialType)
) {
ctx.addIssue({
code: "custom",
message: `Item ${index}: 'credentialType' must be 'password' or 'oauth'`,
});
}
});
} catch (error) {
ctx.addIssue({
code: "custom",
message: "Must be valid JSON",
});
}
}),
default: z.boolean(),
});

View File

@@ -0,0 +1,29 @@
import type { getPresetAuthorData } from "~/lib/utils/presetsDb";
// below types are ai generated
interface IceServer {
urls: string | string[];
username?: string;
credential?: string;
}
interface Preset {
id: string;
name: string;
createdBy: string;
iceServers: string | IceServer[]; // Database returns string, we transform to IceServer[]
shareable: boolean;
createdAt: string;
}
export interface PresetUser {
id: string;
presetId: string;
userId: string;
isDefault: boolean;
addedAt: string;
preset: Preset;
}
export interface ApiResponse {
success: boolean;
data: PresetUser[];
author: Awaited<ReturnType<typeof getPresetAuthorData>>;
}

View File

@@ -0,0 +1,28 @@
interface IceServer {
urls: string | string[];
username?: string;
credential?: string;
}
export interface Preset {
id: string;
name: string;
createdBy: string;
iceServers: string | IceServer[]; // Database returns string, we transform to IceServer[]
shareable: boolean;
createdAt: string;
}
export interface PresetAuthor {
id: string;
fullName: string | null;
profileImageUrl: string | null;
username: string | null;
email: string | null;
}
export interface PresetShareResponse {
success: boolean;
data: Preset;
author: PresetAuthor;
}

165
app/lib/utils/presetsDb.ts Normal file
View File

@@ -0,0 +1,165 @@
import { clerkClient } from "@clerk/nuxt/server";
import { eq, and } from "drizzle-orm";
import { db } from "~/lib/db/index";
import * as schema from "~/lib/db/schema";
import type { H3Event } from "h3";
export async function getUserPresets(clerkUserId: string) {
return await db.query.presetUsers.findMany({
where: eq(schema.presetUsers.userId, clerkUserId),
with: {
preset: true,
},
});
}
export async function getPresetById(presetId: string) {
return await db.query.presets.findFirst({
where: eq(schema.presets.id, presetId),
});
}
export async function userHasPresetAccess(
presetId: string,
userId: string,
): Promise<boolean> {
const preset = await getPresetById(presetId);
if (!preset) return false;
if (preset.createdBy === userId) return true;
const userPreset = await db.query.presetUsers.findFirst({
where: and(
eq(schema.presetUsers.presetId, presetId),
eq(schema.presetUsers.userId, userId),
),
});
return !!userPreset;
}
export async function createPreset(
userId: string,
name: string,
iceServers: string,
isDefault: boolean = false,
) {
const presetCreate = await db
.insert(schema.presets)
.values({
createdBy: userId,
name: name,
iceServers: iceServers,
})
.returning({ insertedId: schema.presets.id });
const insertedId = presetCreate[0]?.insertedId;
if (!insertedId) {
throw new Error("Failed to get inserted preset ID");
}
await db.insert(schema.presetUsers).values({
presetId: insertedId,
userId: userId,
isDefault: isDefault,
});
return insertedId;
}
export async function updatePreset(
presetId: string,
name: string,
iceServers: string,
) {
await db
.update(schema.presets)
.set({
name: name,
iceServers: iceServers,
})
.where(eq(schema.presets.id, presetId));
}
export async function setPresetAsDefault(presetId: string, userId: string) {
await db
.update(schema.presetUsers)
.set({ isDefault: false })
.where(eq(schema.presetUsers.userId, userId));
// set as default
await db
.update(schema.presetUsers)
.set({ isDefault: true })
.where(
and(
eq(schema.presetUsers.presetId, presetId),
eq(schema.presetUsers.userId, userId),
),
);
}
export async function unsetPresetAsDefault(presetId: string, userId: string) {
await db
.update(schema.presetUsers)
.set({ isDefault: false })
.where(
and(
eq(schema.presetUsers.presetId, presetId),
eq(schema.presetUsers.userId, userId),
),
);
}
export async function updatePresetDefaultStatus(
presetId: string,
userId: string,
isDefault: boolean,
) {
if (isDefault) {
await setPresetAsDefault(presetId, userId);
} else {
await unsetPresetAsDefault(presetId, userId);
}
}
export async function deletePreset(presetId: string) {
await db.delete(schema.presets).where(eq(schema.presets.id, presetId));
}
export async function getOwnedPresets(userId: string) {
return await db.query.presets.findMany({
where: eq(schema.presets.createdBy, userId),
});
}
export async function ownsPreset(
presetId: string,
userId: string,
): Promise<boolean> {
const preset = await getPresetById(presetId);
if (!preset) return false;
return preset.createdBy === userId;
}
export async function markAsShareable(presetId: string, shareable: boolean) {
await db
.update(schema.presets)
.set({ shareable })
.where(eq(schema.presets.id, presetId));
}
export async function getPresetAuthorData(event: H3Event, presetId: string) {
const preset = await getPresetById(presetId);
if (!preset) {
throw createError({ statusCode: 404, statusMessage: "Preset not found" });
}
const user = await clerkClient(event).users.getUser(preset.createdBy);
return {
id: user.id,
fullName: user.fullName,
profileImageUrl: user.imageUrl,
username: user.username,
email: user.primaryEmailAddress?.emailAddress || null,
};
}

View File

@@ -0,0 +1,14 @@
// source: https://clerk.com/docs/guides/secure/protect-pages
// Define the routes you want to protect with `createRouteMatcher()`
const isProtectedRoute = createRouteMatcher(["/presets(.*)", "/stream(.*)"]);
export default defineNuxtRouteMiddleware((to) => {
// Use the `useAuth()` composable to access the `isSignedIn` property
const { isSignedIn } = useAuth();
// Check if the user is not signed in and is trying to access a protected route
// If so, redirect them to the sign-in page
if (!isSignedIn.value && isProtectedRoute(to)) {
return navigateTo("/sign-in");
}
});

View File

@@ -1,59 +1,117 @@
<template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4 min-h-[80vh]">
<div v-if="!isConnected" class="flex flex-col items-center gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="text-center space-y-2">
<h1 class="text-4xl font-bold tracking-tight">helium</h1>
<p class="text-muted-foreground text-lg">effortless screensharing powered by webrtc</p>
</div>
<app-code-input />
<NuxtLink to="/stream">
<Button variant="link" class="text-muted-foreground hover:text-primary">
host instead?
</Button>
</NuxtLink>
</div>
<div
class="video transition-all duration-500 ease-in-out"
:class="[
isConnected
? 'fixed inset-0 z-50 w-full h-full bg-black'
: 'relative w-full max-w-3xl aspect-video rounded-xl overflow-hidden border shadow-sm bg-muted/50'
]"
>
<!-- Status Overlay -->
<div
v-if="!isConnected"
class="absolute inset-0 flex items-center justify-center z-10 p-4 text-center"
>
<div v-if="viewerStore.isDisconnected" class="space-y-4">
<p class="text-sm font-medium text-muted-foreground">stream ended</p>
<Button @click="handleReset" variant="outline">
Enter another code
</Button>
</div>
<div v-else-if="viewerStore.connectionStatus !== 'waiting for a code'" class="space-y-4">
<div class="animate-spin w-8 h-8 border-4 border-primary border-t-transparent rounded-full mx-auto" />
<p class="text-sm font-medium text-muted-foreground">{{ viewerStore.connectionStatus }}</p>
</div>
<p v-else class="text-muted-foreground/50 text-sm">
enter code to join stream
</p>
</div>
<!-- Video Feed -->
<video
ref="videofeedRef"
autoplay
playsinline
:controls="false"
class="w-full h-full object-contain bg-black"
@loadeddata="isConnected = true"
/>
<!-- Connected Controls Overlay -->
<div
v-if="isConnected"
class="absolute top-0 left-0 right-0 p-4 flex justify-between items-start opacity-0 hover:opacity-100 transition-opacity bg-gradient-to-b from-black/50 to-transparent"
>
<Button
variant="destructive"
size="lg"
class="gap-2 shadow-lg"
@click="cleanupViewing"
>
<LogOut class="w-5 h-5" />
Disconnect
</Button>
<Button
variant="secondary"
size="lg"
class="gap-2 shadow-lg"
@click="toggleFullscreen"
>
<Maximize class="w-5 h-5" />
Fullscreen
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useWebSocket } from '@vueuse/core';
import { useViewerStore } from '~/state/viewer';
import { Button } from "@/components/ui/button"
import { useWebSocketUrl } from '~/composables/useWebSocketUrl';
import { useWebSocket } from "@vueuse/core";
import { useViewerStore } from "~/state/viewer";
import { Button } from "@/components/ui/button";
import { useWebSocketUrl } from "~/composables/useWebSocketUrl";
import { LogOut, Maximize } from "lucide-vue-next";
const isConnected = ref(false);
const viewerStore = useViewerStore()
const { code: codeRef } = storeToRefs(viewerStore)
const wsUrl = useWebSocketUrl()
const { send } = useWebSocket(wsUrl, {
const viewerStore = useViewerStore();
const { code: codeRef } = storeToRefs(viewerStore);
const wsUrl = useWebSocketUrl();
const videofeedRef = ref<HTMLVideoElement | null>(null);
const { send, close: closeWebSocket } = useWebSocket(wsUrl, {
autoReconnect: true,
heartbeat: {
message: JSON.stringify({ event: 'ping' }),
message: JSON.stringify({ event: "ping" }),
interval: 15000,
},
onMessage: async (ws, ev) => {
const message = JSON.parse(ev.data)
if (message.event === 'offer') {
viewerStore.setConnectionStatus('creating rtc peer connections...')
const message = JSON.parse(ev.data);
if (message.event === "offer") {
viewerStore.setConnectionStatus("creating rtc peer connections...");
const peerConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{
urls: 'turn:5.161.207.54:3478',
username: 'username',
credential: 'password',
},
{
urls: 'turn:5.161.49.183:3478',
username: 'username',
credential: 'password',
},
{
urls: 'turn:135.181.147.65:3478',
username: 'username',
credential: 'password',
},
{
urls: 'turn:5.78.83.26:3478',
username: 'username',
credential: 'password',
},
{
urls: 'turn:5.223.48.157:3478',
username: 'username',
credential: 'password',
},
],
iceTransportPolicy: 'relay',
iceServers: message.iceServers,
});
viewerStore.setPeerConnection(peerConnection);
peerConnection.ontrack = (event) => {
viewerStore.setConnectionStatus('got some tracks!')
viewerStore.setConnectionStatus("got some tracks!");
if (event.streams && event.streams[0] && videofeedRef.value) {
videofeedRef.value.srcObject = event.streams[0];
}
@@ -61,92 +119,186 @@ const { send } = useWebSocket(wsUrl, {
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
viewerStore.setConnectionStatus(`got an ice candidate (type: ${event.candidate.type})`)
send(JSON.stringify({
event: 'ice-candidate',
viewerStore.setConnectionStatus(
`got an ice candidate (type: ${event.candidate.type})`,
);
send(
JSON.stringify({
event: "ice-candidate",
targetId: message.senderId,
candidate: event.candidate,
}))
}),
);
}
};
peerConnection.onconnectionstatechange = () => {
viewerStore.setConnectionStatus(`connection state: ${peerConnection.connectionState}`);
viewerStore.setConnectionStatus(
`connection state: ${peerConnection.connectionState}`,
);
if (peerConnection.connectionState === 'connected') {
viewerStore.setConnectionStatus('connected!');
if (peerConnection.connectionState === "connected") {
viewerStore.setConnectionStatus("connected!");
}
// Handle disconnection or failed connection
if (
peerConnection.connectionState === "disconnected" ||
peerConnection.connectionState === "failed" ||
peerConnection.connectionState === "closed"
) {
viewerStore.setConnectionStatus(
`connection ${peerConnection.connectionState}`,
);
// Don't set isConnected = false immediately here to avoid flickering if it's a temp glitch,
// but usually disconnected means it's over.
if (peerConnection.connectionState !== "connected") {
isConnected.value = false;
}
}
};
peerConnection.oniceconnectionstatechange = () => {
viewerStore.setConnectionStatus(`ice connection state: ${peerConnection.iceConnectionState}`);
viewerStore.setConnectionStatus(
`ice connection state: ${peerConnection.iceConnectionState}`,
);
};
peerConnection.onicegatheringstatechange = () => {
viewerStore.setConnectionStatus(`ice gathering state: ${peerConnection.iceGatheringState}`);
viewerStore.setConnectionStatus(
`ice gathering state: ${peerConnection.iceGatheringState}`,
);
};
viewerStore.setConnectionStatus('sending an sdp description')
await peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp));
viewerStore.setConnectionStatus("sending an sdp description");
try {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(message.sdp),
);
} catch (error) {
console.error("Error setting remote description:", error);
viewerStore.setConnectionStatus("failed to connect");
return;
}
viewerStore.setConnectionStatus('sending an answer')
viewerStore.setConnectionStatus("sending an answer");
try {
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
send(JSON.stringify({
event: 'answer',
send(
JSON.stringify({
event: "answer",
targetId: message.senderId,
sdp: answer,
}))
}),
);
} catch (error) {
console.error("Error creating answer:", error);
viewerStore.setConnectionStatus("failed to send answer");
}
}
if (message.event === 'ice-candidate') {
if (viewerStore.peerConnection && viewerStore.peerConnection.remoteDescription) {
viewerStore.setConnectionStatus(`got an ice candidate from remote peer (type: ${message.candidate.type})`)
await viewerStore.peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
if (message.event === "ice-candidate") {
if (
viewerStore.peerConnection &&
viewerStore.peerConnection.remoteDescription
) {
viewerStore.setConnectionStatus(
`got an ice candidate from remote peer (type: ${message.candidate.type})`,
);
try {
await viewerStore.peerConnection.addIceCandidate(
new RTCIceCandidate(message.candidate),
);
} catch (error) {
console.error("Error adding ICE candidate:", error);
}
}
}
if (message.event === "room-closed") {
viewerStore.setConnectionStatus("room closed by host");
cleanupViewing();
isConnected.value = false;
}
},
});
const videofeedRef = ref<HTMLVideoElement|null>(null);
const startWebRTCConnection = async () => {
send(JSON.stringify({
event: 'join-room',
send(
JSON.stringify({
event: "join-room",
roomId: viewerStore.code,
}))
}
}),
);
};
watch(codeRef, (newCode) => {
// sort of a safeguard bc only 6 digit codes end up getting passed
if (newCode.length === 6) {
startWebRTCConnection();
}
})
});
function cleanupViewing() {
// Close peer connection
if (viewerStore.peerConnection) {
viewerStore.peerConnection.close();
viewerStore.setPeerConnection(null);
}
// Clear video element
if (videofeedRef.value) {
videofeedRef.value.srcObject = null;
}
// Clear code
viewerStore.code = '';
// Reset connection status
viewerStore.setConnectionStatus("disconnected");
isConnected.value = false;
// Exit fullscreen if active
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
function toggleFullscreen() {
if (!videofeedRef.value) return;
if (!document.fullscreenElement) {
videofeedRef.value.requestFullscreen().catch((err) => {
console.error(`Error attempting to enable fullscreen: ${err.message}`);
});
} else {
document.exitFullscreen();
}
}
function handleReset() {
viewerStore.resetDisconnected();
viewerStore.setConnectionStatus('waiting for a code');
}
// Cleanup on component unmount
onBeforeUnmount(() => {
cleanupViewing();
closeWebSocket();
});
// Cleanup on window/tab close
onMounted(() => {
const handleBeforeUnload = () => {
cleanupViewing();
};
window.addEventListener("beforeunload", handleBeforeUnload);
onUnmounted(() => {
window.removeEventListener("beforeunload", handleBeforeUnload);
});
});
</script>
<template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
<h1>helium</h1>
<p>effortless screensharing powered by webrtc</p>
<app-code-input />
<div class="video relative w-full max-w-1/2 aspect-video">
<div v-if="!isConnected" class="absolute inset-0 bg-black flex items-center justify-center z-10 text-white">
{{ viewerStore.connectionStatus }}
</div>
<video
ref="videofeedRef"
autoplay
playsinline
controls
class="bg-black w-full h-full"
@loadeddata="isConnected = true"
/>
</div>
<NuxtLink to="/stream"><Button>host instead?</Button></NuxtLink>
</div>
</template>

153
app/pages/presets/index.vue Normal file
View File

@@ -0,0 +1,153 @@
<template>
<div class="px-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Presets</h1>
<Button @click="navigateTo('/presets/new')">
<Plus class="mr-2 h-4 w-4" />
Create New Preset
</Button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="presetUser in data!.data" :key="presetUser.preset.id">
<Card class="flex flex-col h-full">
<CardHeader>
<CardTitle class="flex items-center justify-between">
<span>{{ presetUser.preset.name }}</span>
<Badge v-if="presetUser.isDefault" variant="secondary"
>Default</Badge
>
</CardTitle>
<CardDescription
>Created by {{ presetUser.preset.createdBy }}</CardDescription
>
</CardHeader>
<CardContent class="grow">
<div class="text-sm text-muted-foreground truncate">
{{ presetUser.preset.iceServers.length }} ICE Server{{
presetUser.preset.iceServers.length === 1 ? "" : "s"
}}
configured
</div>
</CardContent>
<CardFooter class="flex justify-between gap-2 ml-auto">
<div class="flex gap-2">
<div
v-if="user?.id === presetUser.preset.createdBy"
class="flex gap-2"
>
<Button
variant="outline"
size="icon"
@click="handleShare(presetUser.preset)"
>
<Share2 />
</Button>
<Button
variant="outline"
size="icon"
@click="editPreset(presetUser)"
>
<Edit />
</Button>
</div>
<Button
variant="destructive"
size="icon"
@click="deletePreset(presetUser.preset.id)"
>
<Trash />
</Button>
</div>
</CardFooter>
</Card>
</div>
</div>
<EditPresetDialog
v-if="selectedPresetUser"
:open="isEditDialogOpen"
@update:open="isEditDialogOpen = $event"
:preset-user="selectedPresetUser"
@refresh="refresh"
/>
</div>
</template>
<script setup lang="ts">
import { toast } from "vue-sonner";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import EditPresetDialog from "~/components/app/EditPresetDialog.vue";
import { Edit, Share2, Trash, Plus } from "lucide-vue-next";
import type { ApiResponse } from "~/lib/types/PresetGetResponse";
const { user } = useUser();
const { data, refresh } = await useFetch<ApiResponse>("/api/presets", {
cache: "no-cache",
transform: (response: any) => {
return {
...response,
data: response.data.map((item: any) => ({
...item,
preset: {
...item.preset,
iceServers:
typeof item.preset.iceServers === "string"
? JSON.parse(item.preset.iceServers)
: item.preset.iceServers,
},
})),
};
},
});
const isEditDialogOpen = ref(false);
const selectedPresetUser = ref(null);
function editPreset(presetUser: any) {
selectedPresetUser.value = presetUser;
isEditDialogOpen.value = true;
}
async function deletePreset(id: string) {
if (!confirm("Are you sure you want to delete this preset?")) return;
try {
await $fetch(`/api/presets/${id}`, {
method: "DELETE",
});
toast.success("Preset deleted successfully");
refresh();
} catch (error) {
toast.error("Failed to delete preset");
}
}
async function handleShare(preset: any) {
if (!confirm("Do you want to share this preset?")) return;
try {
const response = await $fetch(`/api/presets/${preset.id}/share`, {
method: "POST",
});
if (!response.success) {
toast.error("Failed to generate shareable link");
return;
}
const shareableLink = `${window.location.origin}/presets/shared/${preset.id}`;
navigator.clipboard.writeText(shareableLink);
toast.success("Link copied to clipboard");
} catch (error) {
toast.error("Failed to share preset");
}
}
</script>

11
app/pages/presets/new.vue Normal file
View File

@@ -0,0 +1,11 @@
<template>
<div class="flex flex-col max-w-2xl m-auto">
<PresetForm @success="router.push('/presets')" />
</div>
</template>
<script setup lang="ts">
import PresetForm from "~/components/app/PresetForm.vue";
const router = useRouter();
</script>

View File

@@ -0,0 +1,146 @@
<template>
<div class="min-h-[80vh] flex items-center justify-center p-4">
<div v-if="pending" class="flex justify-center">
<Spinner />
</div>
<Card
v-else-if="response && response.data.shareable"
class="w-full max-w-lg shadow-lg"
>
<CardHeader class="border-b pb-6">
<div class="flex items-center gap-3 mb-2">
<img
v-if="response.author?.profileImageUrl"
:src="response.author.profileImageUrl"
class="w-8 h-8 rounded-full ring-2 ring-background"
alt="Profile"
/>
<div
v-else
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary ring-2 ring-background"
>
{{
(response.author?.fullName ||
response.author?.username ||
response.author?.email ||
"?")[0]!.toUpperCase()
}}
</div>
<CardDescription class="text-sm font-medium">
<span class="text-foreground font-semibold">
{{ response.author?.fullName || response.author?.username }}
</span>
wants to share a preset with you
</CardDescription>
</div>
<CardTitle class="text-2xl pt-2">{{ response.data.name }}</CardTitle>
</CardHeader>
<CardContent class="pt-4">
<div class="grid grid-cols-2 gap-6">
<div class="space-y-1">
<p
class="text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Created
</p>
<p class="text-sm font-medium">
{{
new Date(response.data.createdAt).toLocaleDateString(
undefined,
{ dateStyle: "medium" },
)
}}
</p>
</div>
<div class="space-y-1">
<p
class="text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
Servers
</p>
<div class="flex items-center gap-2">
<span class="text-sm font-medium"
>{{ parsedIceServers.length }} ICE Servers</span
>
</div>
</div>
</div>
<div class="mt-8 flex justify-end gap-3">
<Button variant="outline" @click="navigateTo('/')">Cancel</Button>
<Button @click="importPreset" :disabled="isImporting">
{{ isImporting ? "Importing..." : "Import Preset" }}
</Button>
</div>
</CardContent>
</Card>
<div v-else-if="!response" class="text-center text-muted-foreground">
<p>Preset not found.</p>
</div>
<div
v-else-if="response && !response.data.shareable"
class="text-center text-muted-foreground space-y-4"
>
<p class="text-lg font-semibold text-destructive">
This preset cannot be shared
</p>
<p class="text-sm">
The preset owner has not enabled sharing for this preset.
</p>
<Button variant="outline" @click="navigateTo('/')">Go Back</Button>
</div>
</div>
</template>
<script setup lang="ts">
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { PresetShareResponse } from "~/lib/types/PresetShareResponse";
import { toast } from "vue-sonner";
const route = useRoute();
const { data: response, pending } = useFetch<PresetShareResponse>(
`/api/presets/${route.params.id}`,
);
const isImporting = ref(false);
const parsedIceServers = computed(() => {
if (!response.value?.data.iceServers) return [];
const servers = response.value.data.iceServers;
return typeof servers === "string" ? JSON.parse(servers) : servers;
});
const importPreset = async () => {
isImporting.value = true;
try {
const result = await $fetch<{ success: boolean; message: string }>(
`/api/presets/${route.params.id}/import`,
{
method: "POST",
},
);
if (result.success) {
// Navigate to presets page after successful import
await navigateTo("/presets");
} else {
// Show error message
console.error(result.message);
toast.error(result.message);
}
} catch (error) {
console.error("Failed to import preset:", error);
} finally {
isImporting.value = false;
}
};
</script>

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex h-full w-full items-center justify-center">
<SignIn routing ="path" path="/sign-in" />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex h-full w-full items-center justify-center">
<SignUp routing="path" path="/sign-up" />
</div>
</template>

View File

@@ -1,107 +1,116 @@
<script setup lang="ts">
import { useWebSocket } from '@vueuse/core';
import { Button } from "@/components/ui/button"
import { useStreamerStore } from '~/state/streamer';
import { useWebSocketUrl } from '~/composables/useWebSocketUrl';
<template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
<div class="flex space-x-4 items-center">
<Button @click="startScreenShare"> screenshare </Button>
<PresetSelect />
</div>
<p v-if="streamerStore.code" class="font-mono">{{ streamerStore.code }}</p>
<video ref="videofeedRef" autoplay playsinline muted></video>
</div>
</template>
const streamerStore = useStreamerStore()
<script setup lang="ts">
import { useWebSocket } from "@vueuse/core";
import { Button } from "@/components/ui/button";
import { useStreamerStore } from "~/state/streamer";
import { useWebSocketUrl } from "~/composables/useWebSocketUrl";
import PresetSelect from "~/components/app/PresetSelect.vue";
const streamerStore = useStreamerStore();
const videofeedRef = ref<HTMLVideoElement | null>(null);
const localStream = ref<MediaStream | null>(null);
const wsUrl = useWebSocketUrl()
const wsUrl = useWebSocketUrl();
const { send } = useWebSocket(wsUrl, {
const { send, close: closeWebSocket } = useWebSocket(wsUrl, {
autoReconnect: true,
heartbeat: {
message: JSON.stringify({ event: 'ping' }),
message: JSON.stringify({ event: "ping" }),
interval: 15000,
},
onMessage: async (ws, ev) => {
const message = JSON.parse(ev.data)
const message = JSON.parse(ev.data);
if (message.event === 'room-created') {
streamerStore.setCode(message.roomId)
if (message.event === "room-created") {
streamerStore.setCode(message.roomId);
}
if (message.event === 'viewer-joined') {
if (message.event === "viewer-joined") {
const peerConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{
urls: 'turn:5.161.207.54:3478',
username: 'username',
credential: 'password',
},
{
urls: 'turn:5.161.49.183:3478',
username: 'username',
credential: 'password',
},
{
urls: 'turn:135.181.147.65:3478',
username: 'username',
credential: 'password',
},
{
urls: 'turn:5.78.83.26:3478',
username: 'username',
credential: 'password',
},
{
urls: 'turn:5.223.48.157:3478',
username: 'username',
credential: 'password',
},
],
iceTransportPolicy: 'relay',
iceServers: streamerStore.iceServers,
});
streamerStore.addPeerConnection(message.viewerId, peerConnection)
streamerStore.addPeerConnection(message.viewerId, peerConnection);
// Add media tracks to peer connection
if (localStream.value) {
localStream.value.getTracks().forEach(track => {
localStream.value.getTracks().forEach((track) => {
peerConnection.addTrack(track, localStream.value!);
});
}
// Handle ICE candidates
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
send(JSON.stringify({
event: 'ice-candidate',
send(
JSON.stringify({
event: "ice-candidate",
targetId: message.viewerId,
candidate: event.candidate,
}))
}),
);
}
};
peerConnection.onconnectionstatechange = () => {
console.log(
`connection state with ${message.viewerId}: ${peerConnection.connectionState}`,
);
};
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
send(JSON.stringify({
event: 'offer',
send(
JSON.stringify({
event: "offer",
targetId: message.viewerId,
sdp: offer,
}))
iceServers: streamerStore.iceServers,
}),
);
}
if (message.event === 'ice-candidate') {
if (message.event === "ice-candidate") {
const pc = streamerStore.peerConnections[message.from];
if (pc) {
try {
await pc.addIceCandidate(new RTCIceCandidate(message.candidate));
} catch (error) {
console.error("Error adding ICE candidate:", error);
}
}
}
if (message.event === 'answer') {
if (message.event === "answer") {
const pc = streamerStore.peerConnections[message.from];
if (pc) {
try {
await pc.setRemoteDescription(new RTCSessionDescription(message.sdp));
} catch (error) {
console.error("Error setting remote description:", error);
}
}
}
if (message.event === "viewer-left") {
const pc = streamerStore.peerConnections[message.viewerId];
if (pc) {
pc.close();
streamerStore.removePeerConnection(message.viewerId);
}
}
},
});
async function startScreenShare() {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
@@ -113,18 +122,68 @@ async function startScreenShare() {
videofeedRef.value.srcObject = stream;
}
send(JSON.stringify({
event: 'create-room',
}))
}
</script>
// Detect when user stops sharing via browser UI
stream.getTracks().forEach((track) => {
track.onended = () => {
console.log("Screen sharing stopped by user");
cleanupStreaming();
};
});
<template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
<Button @click="startScreenShare">
screenshare
</Button>
<p v-if="streamerStore.code" class="font-mono">{{ streamerStore.code }}</p>
<video ref="videofeedRef" autoplay playsinline muted></video>
</div>
</template>
send(
JSON.stringify({
event: "create-room",
}),
);
} catch (error) {
console.error("Failed to start screen share:", error);
// User cancelled or permission denied
cleanupStreaming();
}
}
function cleanupStreaming() {
// Stop all media tracks
if (localStream.value) {
localStream.value.getTracks().forEach((track) => {
track.stop();
});
localStream.value = null;
}
// Close all peer connections
Object.values(streamerStore.peerConnections).forEach((pc) => {
pc.close();
});
// Clear peer connections from store
streamerStore.clearPeerConnections();
// Clear video element
if (videofeedRef.value) {
videofeedRef.value.srcObject = null;
}
// Clear room code
streamerStore.setCode("");
}
// Cleanup on component unmount
onBeforeUnmount(() => {
cleanupStreaming();
closeWebSocket();
});
// Cleanup on window/tab close
onMounted(() => {
const handleBeforeUnload = () => {
cleanupStreaming();
};
window.addEventListener("beforeunload", handleBeforeUnload);
onUnmounted(() => {
window.removeEventListener("beforeunload", handleBeforeUnload);
});
});
</script>

View File

@@ -1,9 +1,10 @@
import { defineStore} from 'pinia';
import { defineStore } from "pinia";
export const useStreamerStore = defineStore('streamer', {
export const useStreamerStore = defineStore("streamer", {
state: () => ({
code: '',
code: "",
peerConnections: {} as Record<string, RTCPeerConnection>,
iceServers: [] as RTCIceServer[],
}),
actions: {
setCode(code: string) {
@@ -12,5 +13,15 @@ export const useStreamerStore = defineStore('streamer', {
addPeerConnection(id: string, pc: RTCPeerConnection) {
this.peerConnections[id] = pc;
},
removePeerConnection(id: string) {
delete this.peerConnections[id];
},
clearPeerConnections() {
this.peerConnections = {};
},
setIceServers(iceServers: RTCIceServer[]) {
this.iceServers = iceServers;
},
},
});

View File

@@ -5,12 +5,13 @@ export const useViewerStore = defineStore('viewer', {
code: '',
peerConnection: null as RTCPeerConnection | null,
connectionStatus: 'waiting for a code',
isDisconnected: false,
}),
actions: {
setCode(code: string) {
this.code = code;
},
setPeerConnection(pc: RTCPeerConnection) {
setPeerConnection(pc: RTCPeerConnection | null) {
this.peerConnection = pc;
},
setConnectionStatus(status: string) {
@@ -18,6 +19,12 @@ export const useViewerStore = defineStore('viewer', {
console.log('pinia connection status debug:', status);
}
this.connectionStatus = status;
if (status === 'disconnected') {
this.isDisconnected = true;
}
},
resetDisconnected() {
this.isDisconnected = false;
}
},
});

View File

@@ -0,0 +1,19 @@
CREATE TABLE "preset_users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"preset_id" uuid NOT NULL,
"user_id" text NOT NULL,
"is_default" boolean DEFAULT false NOT NULL,
"added_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "presets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_by" text NOT NULL,
"name" text NOT NULL,
"ice_servers" text NOT NULL,
"shareable" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "preset_users" ADD CONSTRAINT "preset_users_preset_id_presets_id_fk" FOREIGN KEY ("preset_id") REFERENCES "public"."presets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "preset_users_preset_id_user_id_index" ON "preset_users" USING btree ("preset_id","user_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "presets" ADD CONSTRAINT "presets_name_unique" UNIQUE("name");

View File

@@ -0,0 +1,291 @@
{
"id": "2d7f156d-41b3-41bc-83a1-e2fe9e5ba17d",
"prevId": "bede7229-4dec-4922-b74e-3b2d146e8f85",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.peers": {
"name": "peers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"last_seen": {
"name": "last_seen",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preset_users": {
"name": "preset_users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"preset_id": {
"name": "preset_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_default": {
"name": "is_default",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"added_at": {
"name": "added_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"preset_users_preset_id_user_id_index": {
"name": "preset_users_preset_id_user_id_index",
"columns": [
{
"expression": "preset_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"preset_users_preset_id_presets_id_fk": {
"name": "preset_users_preset_id_presets_id_fk",
"tableFrom": "preset_users",
"tableTo": "presets",
"columnsFrom": [
"preset_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.presets": {
"name": "presets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ice_servers": {
"name": "ice_servers",
"type": "text",
"primaryKey": false,
"notNull": true
},
"shareable": {
"name": "shareable",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.room_viewers": {
"name": "room_viewers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"viewer_id": {
"name": "viewer_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"joined_at": {
"name": "joined_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"room_viewers_room_id_rooms_id_fk": {
"name": "room_viewers_room_id_rooms_id_fk",
"tableFrom": "room_viewers",
"tableTo": "rooms",
"columnsFrom": [
"room_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"room_viewers_viewer_id_peers_id_fk": {
"name": "room_viewers_viewer_id_peers_id_fk",
"tableFrom": "room_viewers",
"tableTo": "peers",
"columnsFrom": [
"viewer_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.rooms": {
"name": "rooms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"broadcaster": {
"name": "broadcaster",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"rooms_broadcaster_peers_id_fk": {
"name": "rooms_broadcaster_peers_id_fk",
"tableFrom": "rooms",
"tableTo": "peers",
"columnsFrom": [
"broadcaster"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,299 @@
{
"id": "8c94aa55-1f21-4db7-9adb-26c17d4075a5",
"prevId": "2d7f156d-41b3-41bc-83a1-e2fe9e5ba17d",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.peers": {
"name": "peers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"last_seen": {
"name": "last_seen",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preset_users": {
"name": "preset_users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"preset_id": {
"name": "preset_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_default": {
"name": "is_default",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"added_at": {
"name": "added_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"preset_users_preset_id_user_id_index": {
"name": "preset_users_preset_id_user_id_index",
"columns": [
{
"expression": "preset_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"preset_users_preset_id_presets_id_fk": {
"name": "preset_users_preset_id_presets_id_fk",
"tableFrom": "preset_users",
"tableTo": "presets",
"columnsFrom": [
"preset_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.presets": {
"name": "presets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ice_servers": {
"name": "ice_servers",
"type": "text",
"primaryKey": false,
"notNull": true
},
"shareable": {
"name": "shareable",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"presets_name_unique": {
"name": "presets_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.room_viewers": {
"name": "room_viewers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"room_id": {
"name": "room_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"viewer_id": {
"name": "viewer_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"joined_at": {
"name": "joined_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"room_viewers_room_id_rooms_id_fk": {
"name": "room_viewers_room_id_rooms_id_fk",
"tableFrom": "room_viewers",
"tableTo": "rooms",
"columnsFrom": [
"room_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"room_viewers_viewer_id_peers_id_fk": {
"name": "room_viewers_viewer_id_peers_id_fk",
"tableFrom": "room_viewers",
"tableTo": "peers",
"columnsFrom": [
"viewer_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.rooms": {
"name": "rooms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"broadcaster": {
"name": "broadcaster",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"rooms_broadcaster_peers_id_fk": {
"name": "rooms_broadcaster_peers_id_fk",
"tableFrom": "rooms",
"tableTo": "peers",
"columnsFrom": [
"broadcaster"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,20 @@
"when": 1765910486192,
"tag": "0000_pale_maximus",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1767895147484,
"tag": "0001_material_puma",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1767985505802,
"tag": "0002_jazzy_onslaught",
"breakpoints": true
}
]
}

View File

@@ -1,33 +1,46 @@
import tailwindcss from "@tailwindcss/vite";
import { shadcn } from "@clerk/themes";
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
css: ['~/assets/css/tailwind.css'],
css: ["~/assets/css/tailwind.css"],
vite: {
plugins: [
tailwindcss(),
],
plugins: [tailwindcss()],
server: {
allowedHosts: ["urods-79-145-156-36.a.free.pinggy.link"],
},
modules: ['shadcn-nuxt', '@nuxtjs/color-mode', '@pinia/nuxt', 'nuxt-cron'],
},
modules: [
"shadcn-nuxt",
"@nuxtjs/color-mode",
"@pinia/nuxt",
"nuxt-cron",
"@clerk/nuxt",
"nuxt-monaco-editor",
],
colorMode: {
classSuffix: ''
classSuffix: "",
},
shadcn: {
/**
* Prefix for all the imported component
*/
prefix: '',
prefix: "",
/**
* Directory that the component lives in.
* @default "./components/ui"
*/
componentDir: './components/ui'
componentDir: "./components/ui",
},
nitro: {
experimental: {
websocket: true
}
websocket: true,
},
})
},
clerk: {
appearance: {
theme: shadcn,
},
},
});

View File

@@ -12,17 +12,21 @@
"db:migrate": "drizzle-kit generate && drizzle-kit migrate"
},
"dependencies": {
"@clerk/nuxt": "^1.13.10",
"@clerk/themes": "^2.4.46",
"@neondatabase/serverless": "^1.0.2",
"@pinia/nuxt": "0.11.2",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/vue-form": "^1.27.7",
"@vueuse/core": "^14.0.0",
"better-auth": "^1.4.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"lucide-vue-next": "^0.548.0",
"nuxt": "^4.2.0",
"monaco-editor": "^0.55.1",
"nuxt": "^4.2.1",
"nuxt-monaco-editor": "^1.4.0",
"pinia": "^3.0.3",
"reka-ui": "^2.6.0",
"shadcn-nuxt": "2.3.2",
@@ -31,7 +35,8 @@
"tw-animate-css": "^1.4.0",
"vue": "^3.5.22",
"vue-router": "^4.6.3",
"vue-sonner": "^2.0.9"
"vue-sonner": "^2.0.9",
"zod": "^4.3.5"
},
"devDependencies": {
"@iconify-json/radix-icons": "^1.2.5",

2260
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2181
public/catppuccin-mocha.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
import { getPresetById, deletePreset } from "~/lib/utils/presetsDb";
export default defineEventHandler(async (event) => {
const { isAuthenticated, userId } = event.context.auth();
if (!isAuthenticated || !userId) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
const id = getRouterParam(event, "id");
if (!id) {
throw createError({ statusCode: 400, statusMessage: "Missing preset ID" });
}
// Check if the user is the creator of the preset
const preset = await getPresetById(id);
if (!preset) {
throw createError({ statusCode: 404, statusMessage: "Preset not found" });
}
if (preset.createdBy !== userId) {
throw createError({
statusCode: 403,
statusMessage: "Forbidden: You can only delete your own presets",
});
}
// Delete the preset (cascades to presetUsers)
await deletePreset(id);
return { success: true };
});

View File

@@ -0,0 +1,30 @@
import {
getPresetAuthorData,
getPresetById,
userHasPresetAccess,
} from "~/lib/utils/presetsDb";
export default defineEventHandler(async (event) => {
const { isAuthenticated, userId } = event.context.auth();
if (!isAuthenticated || !userId) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
const id = getRouterParam(event, "id");
if (!id) {
throw createError({ statusCode: 400, statusMessage: "Missing preset ID" });
}
const preset = await getPresetById(id);
if (!preset) {
throw createError({ statusCode: 404, statusMessage: "Preset not found" });
}
const author = await getPresetAuthorData(event, id);
return {
success: true,
data: preset,
author,
};
});

View File

@@ -0,0 +1,44 @@
import {
getPresetById,
updatePreset,
updatePresetDefaultStatus,
} from "~/lib/utils/presetsDb";
export default defineEventHandler(async (event) => {
const { isAuthenticated, userId } = event.context.auth();
if (!isAuthenticated || !userId) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
const id = getRouterParam(event, "id");
if (!id) {
throw createError({ statusCode: 400, statusMessage: "Missing preset ID" });
}
const body = await readBody(event);
// Verify ownership
const preset = await getPresetById(id);
if (!preset) {
throw createError({ statusCode: 404, statusMessage: "Preset not found" });
}
if (preset.createdBy !== userId) {
throw createError({
statusCode: 403,
statusMessage: "Forbidden: You can only edit your own presets",
});
}
// Update preset
await updatePreset(id, body.name, JSON.stringify(body.iceServers));
// Update default status in presetUsers
if (body.default !== undefined) {
await updatePresetDefaultStatus(id, userId, body.default);
}
return { success: true };
});

View File

@@ -0,0 +1,69 @@
import { getPresetById, userHasPresetAccess } from "~/lib/utils/presetsDb";
import { db } from "~/lib/db/index";
import * as schema from "~/lib/db/schema";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
const { isAuthenticated, userId } = event.context.auth();
if (!isAuthenticated || !userId) {
setResponseStatus(event, 401);
return {
success: false,
message: "Unauthorized",
};
}
if (!id) {
setResponseStatus(event, 400);
return {
success: false,
message: "Missing preset ID",
};
}
const preset = await getPresetById(id);
if (!preset) {
setResponseStatus(event, 404);
return {
success: false,
message: "Preset not found",
};
}
if (!preset.shareable) {
setResponseStatus(event, 403);
return {
success: false,
message: "This preset is not shareable",
};
}
// Check if user already has this preset
const userAlreadyHasPreset = await db.query.presetUsers.findFirst({
where: (presetUsers, { eq, and }) =>
and(
eq(presetUsers.presetId, id),
eq(presetUsers.userId, userId),
),
});
if (userAlreadyHasPreset) {
return {
success: false,
message: "You already have this preset imported",
};
}
// Add preset to user
await db.insert(schema.presetUsers).values({
presetId: id,
userId: userId,
isDefault: false,
});
return {
success: true,
message: "Preset imported successfully",
};
});

View File

@@ -0,0 +1,36 @@
import { markAsShareable, ownsPreset } from "~/lib/utils/presetsDb";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
const { isAuthenticated, userId } = event.context.auth();
if (!isAuthenticated || !userId) {
setResponseStatus(event, 401);
return {
success: false,
message: "Unauthorized",
};
}
if (!id) {
setResponseStatus(event, 400);
return {
success: false,
message: "Missing preset ID",
};
}
if (!(await ownsPreset(id, userId))) {
setResponseStatus(event, 403);
return {
success: false,
message: "Forbidden",
};
}
await markAsShareable(id, true);
return {
success: true,
message: "Preset marked as shareable",
};
});

View File

@@ -0,0 +1,51 @@
import { clerkClient } from "@clerk/nuxt/server";
import { schema as zodSchema } from "~/lib/schema/new-preset";
import { createPreset } from "~/lib/utils/presetsDb";
export default defineEventHandler(async (req) => {
const reqBody = await readBody(req);
if (reqBody && reqBody.iceServers) {
reqBody.iceServers = JSON.stringify(reqBody.iceServers);
}
const body = zodSchema.safeParse(reqBody);
if (body.success === false) {
setResponseStatus(req, 400);
return {
success: false,
message: "Invalid request body",
};
}
const { isAuthenticated, userId } = req.context.auth();
if (!isAuthenticated) {
setResponseStatus(req, 401);
return {
success: false,
message: "Unauthorized",
};
}
const user = await clerkClient(req).users.getUser(userId);
try {
await createPreset(
user.id,
body.data.name,
body.data.iceServers,
body.data.default,
);
} catch (e: any) {
setResponseStatus(req, 500);
return {
success: false,
message: "Database error",
};
}
return {
success: true,
message: "Preset created successfully",
};
});

View File

@@ -0,0 +1,15 @@
import { getUserPresets } from "~/lib/utils/presetsDb";
export default defineEventHandler(async (event) => {
const { isAuthenticated, userId } = event.context.auth();
console.log("Fetching presets for user:", userId);
if (!isAuthenticated) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
const data = await getUserPresets(userId);
return {
success: true,
data: data,
};
});

View File

@@ -0,0 +1,15 @@
import { runMigrations } from "../../app/lib/db/migrate";
export default defineNitroPlugin(async () => {
// Run migrations once when the server starts
try {
await runMigrations();
} catch (error) {
console.error("[Server] Failed to run migrations on startup:", error);
// In production, you may want to throw here to prevent server startup
// For now, we'll just log the error and continue
if (process.env.NODE_ENV === "production") {
throw error;
}
}
});

View File

@@ -124,6 +124,7 @@ export default defineWebSocketHandler({
event: 'offer',
sdp: msg.sdp,
senderId: peer.id,
iceServers: msg.iceServers,
}));
}
}