fix: unique constraint on name, and flex redesign

This commit is contained in:
2026-01-13 22:02:40 +01:00
parent 3f92dd2226
commit c70fb7f06e
7 changed files with 393 additions and 35 deletions

View File

@@ -35,11 +35,13 @@ export const roomViewers = pgTable("room_viewers", {
export const presets = pgTable("presets", { export const presets = pgTable("presets", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
createdBy: text("created_by").notNull(), createdBy: text("created_by").notNull(),
name: text("name").notNull().unique(), name: text("name").notNull(),
iceServers: text("ice_servers").notNull(), // stringified json obv iceServers: text("ice_servers").notNull(), // stringified json obv
shareable: boolean("shareable").notNull().default(false), shareable: boolean("shareable").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
}); }, (table) => ({
uniqueUserPresetName: uniqueIndex().on(table.createdBy, table.name),
}));
export const presetUsers = pgTable( export const presetUsers = pgTable(
"preset_users", "preset_users",

View File

@@ -1,9 +1,16 @@
<template> <template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4 min-h-[80vh]"> <div
<div v-if="!isConnected" class="flex flex-col items-center gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500"> class="flex flex-col md:flex-row 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"> <div class="text-center space-y-2">
<h1 class="text-4xl font-bold tracking-tight">helium</h1> <h1 class="text-4xl font-bold tracking-tight">helium</h1>
<p class="text-muted-foreground text-lg">effortless screensharing powered by webrtc</p> <p class="text-muted-foreground text-lg">
effortless screensharing powered by webrtc
</p>
</div> </div>
<app-code-input /> <app-code-input />
@@ -20,7 +27,7 @@
:class="[ :class="[
isConnected isConnected
? 'fixed inset-0 z-50 w-full h-full bg-black' ? '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' : 'relative w-full max-w-3xl aspect-video rounded-xl overflow-hidden border shadow-sm bg-muted/50',
]" ]"
> >
<!-- Status Overlay --> <!-- Status Overlay -->
@@ -34,9 +41,16 @@
Enter another code Enter another code
</Button> </Button>
</div> </div>
<div v-else-if="viewerStore.connectionStatus !== 'waiting for a code'" class="space-y-4"> <div
<div class="animate-spin w-8 h-8 border-4 border-primary border-t-transparent rounded-full mx-auto" /> v-else-if="viewerStore.connectionStatus !== 'waiting for a code'"
<p class="text-sm font-medium text-muted-foreground">{{ viewerStore.connectionStatus }}</p> 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> </div>
<p v-else class="text-muted-foreground/50 text-sm"> <p v-else class="text-muted-foreground/50 text-sm">
enter code to join stream enter code to join stream
@@ -254,7 +268,7 @@ function cleanupViewing() {
} }
// Clear code // Clear code
viewerStore.code = ''; viewerStore.code = "";
// Reset connection status // Reset connection status
viewerStore.setConnectionStatus("disconnected"); viewerStore.setConnectionStatus("disconnected");
@@ -280,7 +294,7 @@ function toggleFullscreen() {
function handleReset() { function handleReset() {
viewerStore.resetDisconnected(); viewerStore.resetDisconnected();
viewerStore.setConnectionStatus('waiting for a code'); viewerStore.setConnectionStatus("waiting for a code");
} }
// Cleanup on component unmount // Cleanup on component unmount
@@ -302,3 +316,4 @@ onMounted(() => {
}); });
}); });
</script> </script>

View File

@@ -66,12 +66,20 @@
</div> </div>
</div> </div>
<div class="mt-8 flex justify-end gap-3"> <div class="mt-8 space-y-4">
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
<label class="text-sm font-medium cursor-pointer"
>Activate by default</label
>
<Switch v-model="setAsDefault" />
</div>
<div class="flex justify-end gap-3">
<Button variant="outline" @click="navigateTo('/')">Cancel</Button> <Button variant="outline" @click="navigateTo('/')">Cancel</Button>
<Button @click="importPreset" :disabled="isImporting"> <Button @click="importPreset" :disabled="isImporting">
{{ isImporting ? "Importing..." : "Import Preset" }} {{ isImporting ? "Importing..." : "Import Preset" }}
</Button> </Button>
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
<div v-else-if="!response" class="text-center text-muted-foreground"> <div v-else-if="!response" class="text-center text-muted-foreground">
@@ -102,6 +110,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import type { PresetShareResponse } from "~/lib/types/PresetShareResponse"; import type { PresetShareResponse } from "~/lib/types/PresetShareResponse";
import { toast } from "vue-sonner"; import { toast } from "vue-sonner";
@@ -112,6 +121,7 @@ const { data: response, pending } = useFetch<PresetShareResponse>(
); );
const isImporting = ref(false); const isImporting = ref(false);
const setAsDefault = ref(false);
const parsedIceServers = computed(() => { const parsedIceServers = computed(() => {
if (!response.value?.data.iceServers) return []; if (!response.value?.data.iceServers) return [];
@@ -126,6 +136,9 @@ const importPreset = async () => {
`/api/presets/${route.params.id}/import`, `/api/presets/${route.params.id}/import`,
{ {
method: "POST", method: "POST",
body: {
setAsDefault: setAsDefault.value,
},
}, },
); );

View File

@@ -0,0 +1,2 @@
ALTER TABLE "presets" DROP CONSTRAINT "presets_name_unique";--> statement-breakpoint
CREATE UNIQUE INDEX "presets_created_by_name_index" ON "presets" USING btree ("created_by","name");

View File

@@ -0,0 +1,313 @@
{
"id": "80084ee5-2522-47c7-aedd-846b3d820e8e",
"prevId": "8c94aa55-1f21-4db7-9adb-26c17d4075a5",
"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": {
"presets_created_by_name_index": {
"name": "presets_created_by_name_index",
"columns": [
{
"expression": "created_by",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"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

@@ -22,6 +22,13 @@
"when": 1767985505802, "when": 1767985505802,
"tag": "0002_jazzy_onslaught", "tag": "0002_jazzy_onslaught",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1768337998925,
"tag": "0003_large_nocturne",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,10 +1,11 @@
import { getPresetById, userHasPresetAccess } from "~/lib/utils/presetsDb"; import { getPresetById, userHasPresetAccess, setPresetAsDefault } from "~/lib/utils/presetsDb";
import { db } from "~/lib/db/index"; import { db } from "~/lib/db/index";
import * as schema from "~/lib/db/schema"; import * as schema from "~/lib/db/schema";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id"); const id = getRouterParam(event, "id");
const { isAuthenticated, userId } = event.context.auth(); const { isAuthenticated, userId } = event.context.auth();
const body = await readBody<{ setAsDefault?: boolean }>(event);
if (!isAuthenticated || !userId) { if (!isAuthenticated || !userId) {
setResponseStatus(event, 401); setResponseStatus(event, 401);
@@ -59,9 +60,14 @@ export default defineEventHandler(async (event) => {
await db.insert(schema.presetUsers).values({ await db.insert(schema.presetUsers).values({
presetId: id, presetId: id,
userId: userId, userId: userId,
isDefault: false, isDefault: body.setAsDefault ?? false,
}); });
// If setAsDefault is true and this is the first import, set it as default
if (body.setAsDefault) {
await setPresetAsDefault(id, userId);
}
return { return {
success: true, success: true,
message: "Preset imported successfully", message: "Preset imported successfully",