mirror of
https://github.com/SrIzan10/helium.git
synced 2026-06-06 00:56:58 +00:00
feat: preliminary presets index
This commit is contained in:
54
app/components/app/EditPresetDialog.vue
Normal file
54
app/components/app/EditPresetDialog.vue
Normal 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>
|
||||
169
app/components/app/PresetForm.vue
Normal file
169
app/components/app/PresetForm.vue
Normal 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>
|
||||
26
app/components/ui/badge/Badge.vue
Normal file
26
app/components/ui/badge/Badge.vue
Normal 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>
|
||||
26
app/components/ui/badge/index.ts
Normal file
26
app/components/ui/badge/index.ts
Normal 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>
|
||||
22
app/components/ui/card/Card.vue
Normal file
22
app/components/ui/card/Card.vue
Normal 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>
|
||||
17
app/components/ui/card/CardAction.vue
Normal file
17
app/components/ui/card/CardAction.vue
Normal 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>
|
||||
17
app/components/ui/card/CardContent.vue
Normal file
17
app/components/ui/card/CardContent.vue
Normal 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>
|
||||
17
app/components/ui/card/CardDescription.vue
Normal file
17
app/components/ui/card/CardDescription.vue
Normal 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>
|
||||
17
app/components/ui/card/CardFooter.vue
Normal file
17
app/components/ui/card/CardFooter.vue
Normal 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>
|
||||
17
app/components/ui/card/CardHeader.vue
Normal file
17
app/components/ui/card/CardHeader.vue
Normal 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>
|
||||
17
app/components/ui/card/CardTitle.vue
Normal file
17
app/components/ui/card/CardTitle.vue
Normal 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>
|
||||
7
app/components/ui/card/index.ts
Normal file
7
app/components/ui/card/index.ts
Normal 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"
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
server/api/presets/[id].delete.ts
Normal file
34
server/api/presets/[id].delete.ts
Normal 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 };
|
||||
});
|
||||
39
server/api/presets/[id].get.ts
Normal file
39
server/api/presets/[id].get.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
59
server/api/presets/[id].put.ts
Normal file
59
server/api/presets/[id].put.ts
Normal 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 };
|
||||
});
|
||||
Reference in New Issue
Block a user