diff --git a/README.md b/README.md index ac4f99b..2d3ef2e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ # helium -effortless webrtc screensharing \ No newline at end of file +effortless webrtc screensharing + diff --git a/app/components/ui/sonner/Sonner.vue b/app/components/ui/sonner/Sonner.vue index 1c70dca..6830896 100644 --- a/app/components/ui/sonner/Sonner.vue +++ b/app/components/ui/sonner/Sonner.vue @@ -1,19 +1,42 @@ diff --git a/app/layouts/default.vue b/app/layouts/default.vue index f454059..50b902d 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -1,6 +1,8 @@ diff --git a/app/lib/db/schema.ts b/app/lib/db/schema.ts index a6e5cce..917247b 100644 --- a/app/lib/db/schema.ts +++ b/app/lib/db/schema.ts @@ -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), + }), +); diff --git a/app/pages/presets/new.vue b/app/pages/presets/new.vue index 875750b..7c4a70f 100644 --- a/app/pages/presets/new.vue +++ b/app/pages/presets/new.vue @@ -1,26 +1,26 @@ @@ -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)", - }, }); }, }); diff --git a/drizzle/0001_material_puma.sql b/drizzle/0001_material_puma.sql new file mode 100644 index 0000000..f82caca --- /dev/null +++ b/drizzle/0001_material_puma.sql @@ -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"); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..d632eb5 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index fb69edd..8091805 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1765910486192, "tag": "0000_pale_maximus", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1767895147484, + "tag": "0001_material_puma", + "breakpoints": true } ] } \ No newline at end of file diff --git a/nuxt.config.ts b/nuxt.config.ts index f356393..b450184 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,6 +1,5 @@ import tailwindcss from "@tailwindcss/vite"; import { shadcn } from "@clerk/themes"; -import monacoEditorPlugin from "vite-plugin-monaco-editor"; // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ compatibilityDate: "2025-07-15", diff --git a/server/api/presets/create.post.ts b/server/api/presets/create.post.ts new file mode 100644 index 0000000..710f745 --- /dev/null +++ b/server/api/presets/create.post.ts @@ -0,0 +1 @@ +export default defineEventHandler(() => "Test post handler");