mirror of
https://github.com/SrIzan10/helium.git
synced 2026-06-06 00:56:58 +00:00
feat: overengineered form and db schema
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import SignInDialog from '~/components/app/SignInDialog.vue';
|
||||
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>
|
||||
@@ -17,5 +19,6 @@ import ThemeDropdown from '~/components/ui/ThemeDropdown.vue';
|
||||
</ClientOnly>
|
||||
</header>
|
||||
<slot />
|
||||
<Toaster />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
boolean,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
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(),
|
||||
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),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 max-w-2xl m-auto">
|
||||
<form id="form-tanstack-input" @submit.prevent="form.handleSubmit">
|
||||
<div class="flex flex-col max-w-2xl m-auto">
|
||||
<form
|
||||
id="form-tanstack-input"
|
||||
@submit.prevent="form.handleSubmit"
|
||||
class="space-y-6"
|
||||
>
|
||||
<FieldGroup>
|
||||
<form.Field v-slot="{ field }" name="username">
|
||||
<form.Field v-slot="{ field }" name="name">
|
||||
<Field :data-invalid="isInvalid(field)">
|
||||
<FieldLabel for="form-tanstack-input-username">
|
||||
Username
|
||||
Preset name
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="form-tanstack-input-username"
|
||||
:name="field.name"
|
||||
:model-value="field.state.value"
|
||||
:aria-invalid="isInvalid(field)"
|
||||
placeholder="shadcn"
|
||||
autocomplete="username"
|
||||
placeholder="My ICE Preset"
|
||||
autocomplete="off"
|
||||
@blur="field.handleBlur"
|
||||
@input="field.handleChange($event.target.value)"
|
||||
/>
|
||||
<FieldDescription>
|
||||
This is your public display name. Must be between 3 and 10
|
||||
characters. Must only contain letters, numbers, and underscores.
|
||||
</FieldDescription>
|
||||
<FieldError
|
||||
v-if="isInvalid(field)"
|
||||
:errors="field.state.meta.errors"
|
||||
@@ -28,22 +28,36 @@
|
||||
</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"
|
||||
>
|
||||
<ClientOnly>
|
||||
<MonacoEditor
|
||||
:model-value="field.state.value"
|
||||
:options="editorOptions"
|
||||
class="h-full w-full"
|
||||
lang="json"
|
||||
@update:model-value="field.handleChange"
|
||||
@blur="field.handleBlur"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<FieldError
|
||||
v-if="isInvalid(field)"
|
||||
:errors="field.state.meta.errors"
|
||||
/>
|
||||
</Field>
|
||||
</form.Field>
|
||||
<Field orientation="horizontal">
|
||||
<!--<Button type="button" variant="outline" @click="form.reset()">
|
||||
Reset
|
||||
</Button>-->
|
||||
<Button type="submit" form="form-tanstack-input">Save</Button>
|
||||
</Field>
|
||||
</form>
|
||||
<div class="h-96 w-full border rounded-md overflow-hidden">
|
||||
<ClientOnly>
|
||||
<MonacoEditor
|
||||
:options="editorOptions"
|
||||
class="h-full w-full"
|
||||
lang="json"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<Field orientation="horizontal">
|
||||
<Button type="button" variant="outline" @click="form.reset()">
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit" form="form-tanstack-input"> Save </Button>
|
||||
</Field>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -80,37 +94,134 @@ if (import.meta.client) {
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z
|
||||
name: z
|
||||
.string()
|
||||
.min(3, "Username must be at least 3 characters.")
|
||||
.max(10, "Username must be at most 10 characters.")
|
||||
.regex(
|
||||
/^\w+$/,
|
||||
"Username can only contain letters, numbers, and underscores.",
|
||||
),
|
||||
.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",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
username: "",
|
||||
name: "",
|
||||
iceServers:
|
||||
'[\n\t{ "urls": "stun:stun.l.google.com:19302" }\,\n\t{ "urls": "stun:stun1.l.google.com:19302" }\n]',
|
||||
},
|
||||
validators: {
|
||||
onSubmit: formSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
// Parse the JSON string back to an object for submission
|
||||
const parsedValue = {
|
||||
...value,
|
||||
iceServers: JSON.parse(value.iceServers),
|
||||
};
|
||||
toast("You submitted the following values:", {
|
||||
description: h(
|
||||
"pre",
|
||||
{
|
||||
class:
|
||||
"bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4",
|
||||
"bg-code text-white mt-2 w-[320px] overflow-x-auto rounded-md p-4",
|
||||
},
|
||||
h("code", JSON.stringify(value, null, 2)),
|
||||
h("code", JSON.stringify(parsedValue, null, 2)),
|
||||
),
|
||||
position: "bottom-right",
|
||||
class: "flex flex-col gap-2",
|
||||
style: {
|
||||
"--border-radius": "calc(var(--radius) + 4px)",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user