feat: preliminary presets index

This commit is contained in:
2026-01-11 00:20:25 +01:00
parent 610a0446a5
commit 409f32090e
17 changed files with 692 additions and 151 deletions

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,169 @@
<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";
}
const request = await $fetch(url, {
method: method as any,
body: JSON.stringify(parsedValue),
});
if (request.success) {
toast.success(props.isEdit ? "Preset updated successfully!" : "Preset created successfully!");
emit('success');
} else {
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,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

@@ -1,9 +1,158 @@
<template>
<div>
<p v-if="data">{{ data.data }}</p>
<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>
<EditPresetDialog
v-if="selectedPresetUser"
:open="isEditDialogOpen"
@update:open="isEditDialogOpen = $event"
:preset-user="selectedPresetUser"
@refresh="refresh"
/>
</div>
</template>
<script setup lang="ts">
const { data } = await useFetch("/api/presets", { cache: "no-cache" });
import { useUser } from "@clerk/vue";
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 } from "lucide-vue-next";
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");
}
}
function handleShare(preset: any) {
// To be implemented by user
console.log("Share preset:", preset.id);
}
// 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;
}
interface PresetUser {
id: string;
presetId: string;
userId: string;
isDefault: boolean;
addedAt: string;
preset: Preset;
}
interface ApiResponse {
success: boolean;
data: PresetUser[];
}
</script>

View File

@@ -1,157 +1,11 @@
<template>
<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="name">
<Field :data-invalid="isInvalid(field)">
<FieldLabel for="form-tanstack-input-username">
Preset name
</FieldLabel>
<Input
id="form-tanstack-input-username"
: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="form-default-preset">
Set as default preset
</FieldLabel>
<FieldDescription>
This preset will be selected by default on the preset selector.
</FieldDescription>
</FieldContent>
<Switch
id="form-default-preset"
:model-value="field.state.value"
@update:model-value="field.handleChange"
@blur="field.handleBlur"
/>
</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>
<PresetForm @success="router.push('/presets')" />
</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";
import PresetForm from "~/components/app/PresetForm.vue";
const router = useRouter();
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: {
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),
};
toast("You submitted the following values:", {
description: h(
"pre",
{
class:
"bg-code text-white mt-2 w-[320px] overflow-x-auto rounded-md p-4",
},
h("code", JSON.stringify(parsedValue, null, 2)),
),
});
const request = await $fetch("/api/presets/create", {
method: "POST",
body: JSON.stringify(parsedValue),
});
if (request.success) {
toast.success("Preset created successfully!");
router.push("/presets");
} else {
toast.error("Failed to create preset.");
}
},
});
function isInvalid(field: any) {
return field.state.meta.isTouched && !field.state.meta.isValid;
}
</script>

View File

@@ -0,0 +1,34 @@
import { eq, and } from "drizzle-orm";
import { db } from "~/lib/db";
import { presets, presetUsers } from "~/lib/db/schema";
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 db.query.presets.findFirst({
where: eq(presets.id, 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 db.delete(presets).where(eq(presets.id, id));
return { success: true };
});

View File

@@ -0,0 +1,39 @@
import { eq } from "drizzle-orm";
import { db } from "~/lib/db";
import { presets, presetUsers } from "~/lib/db/schema";
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" });
}
// Fetch the preset
const preset = await db.query.presets.findFirst({
where: eq(presets.id, id),
});
if (!preset) {
throw createError({ statusCode: 404, statusMessage: "Preset not found" });
}
// Check if user has access (either creator or has it in their presetUsers)
const userPreset = await db.query.presetUsers.findFirst({
where: eq(presetUsers.presetId, id),
});
if (preset.createdBy !== userId && (!userPreset || userPreset.userId !== userId)) {
throw createError({ statusCode: 403, statusMessage: "Forbidden" });
}
return {
success: true,
data: preset,
};
});

View File

@@ -0,0 +1,59 @@
import { eq, and } from "drizzle-orm";
import { db } from "~/lib/db";
import { presets, presetUsers } from "~/lib/db/schema";
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 db.query.presets.findFirst({
where: eq(presets.id, 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 db.update(presets)
.set({
name: body.name,
iceServers: JSON.stringify(body.iceServers),
})
.where(eq(presets.id, id));
// Update default status in presetUsers
if (body.default !== undefined) {
// If setting as default, first unset all other defaults for this user
if (body.default) {
await db.update(presetUsers)
.set({ isDefault: false })
.where(eq(presetUsers.userId, userId));
}
// Update the default status for this preset
await db.update(presetUsers)
.set({ isDefault: body.default })
.where(and(
eq(presetUsers.presetId, id),
eq(presetUsers.userId, userId)
));
}
return { success: true };
});