mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-27 23:46:37 +01:00
Compare commits
7 Commits
copilot/ad
...
tonya/coll
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc7f06210c | ||
|
|
89692b4603 | ||
|
|
c444117a1d | ||
|
|
397aed47a8 | ||
|
|
9e8172657b | ||
|
|
ab57085f8b | ||
|
|
12d6b17318 |
173
frontend/components/Admin/CreateInviteModal.vue
Normal file
173
frontend/components/Admin/CreateInviteModal.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<BaseModal :dialog-id="DialogID.CreateInvite" title="Create Invite">
|
||||
<form class="flex min-w-0 flex-col gap-2" @submit.prevent="createInvite()">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="invite-role">Role</Label>
|
||||
<Select :model-value="form.role" @update:model-value="v => (form.role = String(v))">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">owner</SelectItem>
|
||||
<SelectItem value="admin">admin</SelectItem>
|
||||
<SelectItem value="editor">editor</SelectItem>
|
||||
<SelectItem value="viewer">viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="invite-expires">Expires</Label>
|
||||
<div :class="form.no_expiry ? 'opacity-50 pointer-events-none' : ''">
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full justify-start text-left font-normal"
|
||||
:class="!form.expires_at && 'text-muted-foreground'"
|
||||
>
|
||||
<CalendarIcon class="mr-2 size-4" />
|
||||
{{ formattedExpires ? formattedExpires : "Pick a date" }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Calendar :model-value="localExpires as any" @update:model-value="val => (localExpires = val)" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="invite-max-uses">Max Uses</Label>
|
||||
<Input
|
||||
id="invite-max-uses"
|
||||
v-model.number="form.max_uses"
|
||||
type="number"
|
||||
min="1"
|
||||
:disabled="form.unlimited_uses"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="no-expiry" v-model="form.no_expiry" />
|
||||
<Label for="no-expiry" class="select-none">No expiry</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="unlimited-uses" v-model="form.unlimited_uses" />
|
||||
<Label for="unlimited-uses" class="select-none">Unlimited uses</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-row-reverse gap-2">
|
||||
<Button type="submit">Generate Invite</Button>
|
||||
<Button variant="outline" type="button" @click="cancel">Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, ref, watch } from "vue";
|
||||
import BaseModal from "@/components/App/CreateModal.vue";
|
||||
import { useDialogHotkey, useDialog } from "~/components/ui/dialog-provider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Calendar } from "~/components/ui/calendar";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "~/components/ui/popover";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { Calendar as CalendarIcon } from "lucide-vue-next";
|
||||
import { format } from "date-fns";
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "~/components/ui/select";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { api, type Invite } from "~/mock/collections";
|
||||
import { toast } from "~/components/ui/sonner";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
|
||||
useDialogHotkey(DialogID.CreateInvite, { code: "Digit9" });
|
||||
|
||||
const { closeDialog } = useDialog();
|
||||
|
||||
const form = reactive({
|
||||
role: "viewer",
|
||||
expires_at: undefined as unknown,
|
||||
max_uses: 1,
|
||||
no_expiry: false,
|
||||
unlimited_uses: false,
|
||||
});
|
||||
|
||||
// local date ref to satisfy Calendar's expected Date type
|
||||
const localExpires = ref<Date | undefined>(form.expires_at as Date | undefined);
|
||||
|
||||
watch(
|
||||
() => form.expires_at,
|
||||
v => {
|
||||
localExpires.value = (v as Date) || undefined;
|
||||
}
|
||||
);
|
||||
|
||||
watch(localExpires, v => {
|
||||
form.expires_at = v as unknown;
|
||||
});
|
||||
|
||||
const formattedExpires = computed(() => {
|
||||
const v = form.expires_at as Date | string | undefined | null;
|
||||
if (!v) return null;
|
||||
if (v instanceof Date) return format(v, "PPP");
|
||||
try {
|
||||
const d = new Date(String(v));
|
||||
if (!isNaN(d.getTime())) return format(d, "PPP");
|
||||
} catch (e) {
|
||||
// fallthrough
|
||||
}
|
||||
return String(v);
|
||||
});
|
||||
|
||||
function reset() {
|
||||
form.role = "viewer";
|
||||
form.expires_at = undefined;
|
||||
form.max_uses = 1;
|
||||
form.no_expiry = false;
|
||||
form.unlimited_uses = false;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
reset();
|
||||
closeDialog(DialogID.CreateInvite);
|
||||
}
|
||||
|
||||
function generateCode(length = 6) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let out = "";
|
||||
for (let i = 0; i < length; i++) out += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
return out;
|
||||
}
|
||||
|
||||
function createInvite() {
|
||||
const collectionId = api.getCollections()[0]?.id ?? "";
|
||||
const invite: Partial<Invite> = {
|
||||
id: generateCode(6),
|
||||
collectionId,
|
||||
role: form.role as Invite["role"],
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: form.no_expiry
|
||||
? undefined
|
||||
: form.expires_at
|
||||
? form.expires_at instanceof Date
|
||||
? form.expires_at.toISOString()
|
||||
: String(form.expires_at)
|
||||
: undefined,
|
||||
max_uses: form.unlimited_uses ? undefined : form.max_uses || undefined,
|
||||
uses: 0,
|
||||
};
|
||||
|
||||
api.addInvite(invite);
|
||||
toast.success("Invite created");
|
||||
reset();
|
||||
closeDialog(DialogID.CreateInvite, true);
|
||||
}
|
||||
</script>
|
||||
287
frontend/components/Admin/UserFormDialog.vue
Normal file
287
frontend/components/Admin/UserFormDialog.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, onUnmounted } from "vue";
|
||||
import type { User as MockUser, Collection as MockCollection } from "~/mock/collections";
|
||||
import { api } from "~/mock/collections";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
|
||||
// dialog provider
|
||||
const { closeDialog, registerOpenDialogCallback } = useDialog();
|
||||
|
||||
// local collections snapshot used for checkbox list
|
||||
const availableCollections = ref<MockCollection[]>(api.getCollections() as MockCollection[]);
|
||||
const isNew = ref(true);
|
||||
|
||||
const localEditing = reactive<MockUser>({
|
||||
id: String(Date.now()),
|
||||
name: "",
|
||||
email: "",
|
||||
role: "user",
|
||||
password_set: false,
|
||||
collections: [],
|
||||
});
|
||||
|
||||
const localCollectionIds = ref<string[]>([]);
|
||||
const localNewPassword = ref("");
|
||||
const newAddCollectionId = ref<string>("");
|
||||
|
||||
onMounted(() => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.EditUser, params => {
|
||||
// refresh available collections each time
|
||||
availableCollections.value = api.getCollections() as MockCollection[];
|
||||
|
||||
if (params && (params as { userId?: string }).userId) {
|
||||
const u = api.getUser(params.userId!);
|
||||
if (u) {
|
||||
Object.assign(localEditing, u as MockUser);
|
||||
localCollectionIds.value = (u.collections ?? []).map(c => c.id);
|
||||
isNew.value = false;
|
||||
} else {
|
||||
reset();
|
||||
isNew.value = true;
|
||||
}
|
||||
} else {
|
||||
// new user
|
||||
reset();
|
||||
isNew.value = true;
|
||||
}
|
||||
localNewPassword.value = "";
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
type Membership = { id: string; role: "owner" | "admin" | "editor" | "viewer" };
|
||||
|
||||
function getCollectionName(id: string) {
|
||||
const found = availableCollections.value.find(c => c.id === id);
|
||||
return found ? found.name : id;
|
||||
}
|
||||
|
||||
// localEditing will be set when dialog opens via registerOpenDialogCallback
|
||||
|
||||
function close() {
|
||||
reset();
|
||||
closeDialog(DialogID.EditUser);
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
if (!localEditing.name.trim() || !localEditing.email.trim()) {
|
||||
alert("Name and email are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (localNewPassword.value && localEditing) localEditing.password_set = true;
|
||||
|
||||
const existing = api.getUser(localEditing.id);
|
||||
if (existing) {
|
||||
const updated = {
|
||||
...existing,
|
||||
name: localEditing.name,
|
||||
email: localEditing.email,
|
||||
role: localEditing.role,
|
||||
password_set: localEditing.password_set,
|
||||
} as MockUser;
|
||||
api.updateUser(updated);
|
||||
} else {
|
||||
const toCreate = { ...localEditing, collections: [] } as MockUser;
|
||||
const created = api.addUser(toCreate);
|
||||
localCollectionIds.value.forEach(id => api.addUserToCollection(created.id, id, "viewer"));
|
||||
}
|
||||
|
||||
// close and signal caller to refresh
|
||||
closeDialog(DialogID.EditUser, true);
|
||||
reset();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
localEditing.id = String(Date.now());
|
||||
localEditing.name = "";
|
||||
localEditing.email = "";
|
||||
localEditing.role = "user";
|
||||
localEditing.password_set = false;
|
||||
localEditing.collections = [];
|
||||
localCollectionIds.value = [];
|
||||
localNewPassword.value = "";
|
||||
}
|
||||
|
||||
function removeMembership(id: string) {
|
||||
const existing = (localEditing.collections ?? []) as Membership[];
|
||||
const found = existing.find((x: Membership) => x.id === id);
|
||||
if (found?.role === "owner") {
|
||||
const ok = confirm(
|
||||
`This user is the owner of this collection.\nRemoving the owner will delete the collection. Continue?`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const existsInApi = !!api.getUser(localEditing.id);
|
||||
if (existsInApi) {
|
||||
const ok = api.removeUserFromCollection(localEditing.id, id);
|
||||
if (ok) {
|
||||
localCollectionIds.value = localCollectionIds.value.filter(x => x !== id);
|
||||
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
|
||||
const refreshed = api.getUser(localEditing.id);
|
||||
if (refreshed) Object.assign(localEditing, refreshed as MockUser);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// not in API yet — local only
|
||||
localCollectionIds.value = localCollectionIds.value.filter(x => x !== id);
|
||||
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
|
||||
}
|
||||
|
||||
function addMembership(id: string) {
|
||||
if (!id) return;
|
||||
const existsInApi = !!api.getUser(localEditing.id);
|
||||
if (existsInApi) {
|
||||
const mem = api.addUserToCollection(localEditing.id, id, "viewer");
|
||||
if (mem) {
|
||||
if (!localCollectionIds.value.includes(id)) localCollectionIds.value.push(id);
|
||||
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
|
||||
localEditing.collections.push(mem as Membership);
|
||||
const refreshed = api.getUser(localEditing.id);
|
||||
if (refreshed) Object.assign(localEditing, refreshed as MockUser);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// new user — add locally
|
||||
if (!localCollectionIds.value.includes(id)) localCollectionIds.value.push(id);
|
||||
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
|
||||
localEditing.collections.push({ id, role: "viewer" });
|
||||
}
|
||||
|
||||
function updateMembershipRole(id: string, role: Membership["role"]) {
|
||||
const existsInApi = !!api.getUser(localEditing.id);
|
||||
if (existsInApi) {
|
||||
// best-effort: remove then re-add with new role if API doesn't expose direct update
|
||||
api.removeUserFromCollection(localEditing.id, id);
|
||||
const mem = api.addUserToCollection(localEditing.id, id, role);
|
||||
if (mem) {
|
||||
const refreshed = api.getUser(localEditing.id);
|
||||
if (refreshed) Object.assign(localEditing, refreshed as MockUser);
|
||||
localCollectionIds.value = (localEditing.collections ?? []).map((c: Membership) => c.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// local-only
|
||||
const existing = (localEditing.collections ?? []) as Membership[];
|
||||
const found = existing.find(x => x.id === id);
|
||||
if (found) found.role = role;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :dialog-id="DialogID.EditUser">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isNew ? "Add User" : "Edit User" }}</DialogTitle>
|
||||
<DialogDescription>Manage user details and collection memberships.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form class="flex flex-col gap-3" @submit.prevent="onSave">
|
||||
<label class="block">
|
||||
<div class="mb-1 text-sm">Name</div>
|
||||
<Input v-model="localEditing.name" />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<div class="mb-1 text-sm">Email</div>
|
||||
<Input v-model="localEditing.email" />
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<div class="mb-1 text-sm">Password</div>
|
||||
<Input v-model="localNewPassword" type="password" placeholder="Leave blank to keep" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-sm">Collections</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="m in localEditing.collections ?? []"
|
||||
:key="m.id"
|
||||
class="flex items-center justify-between rounded-lg border py-1 pl-3 pr-1"
|
||||
>
|
||||
<div class="text-lg font-medium">
|
||||
<Badge>
|
||||
{{ getCollectionName(m.id) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Select v-model="m.role" @update:model-value="val => updateMembershipRole(m.id, val)">
|
||||
<SelectTrigger class="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">Owner</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="editor">Editor</SelectItem>
|
||||
<SelectItem value="viewer">Viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
class="ml-2"
|
||||
:title="$t ? $t('global.remove') : 'Remove'"
|
||||
@click.prevent="removeMembership(m.id)"
|
||||
>
|
||||
<MdiClose class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<Select v-model="newAddCollectionId">
|
||||
<SelectTrigger class="flex-1">
|
||||
<SelectValue placeholder="Select collection to add" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="c in availableCollections.filter(c => !localCollectionIds.includes(c.id))"
|
||||
:key="c.id"
|
||||
:value="c.id"
|
||||
>
|
||||
{{ c.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
class="ml-2 w-10 px-0"
|
||||
variant="default"
|
||||
size="lg"
|
||||
:disabled="!newAddCollectionId"
|
||||
@click="addMembership(newAddCollectionId)"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" type="button" @click="close">Cancel</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
142
frontend/components/App/CollectionSelector.vue
Normal file
142
frontend/components/App/CollectionSelector.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:size="sidebar.state.value === 'collapsed' ? 'icon' : undefined"
|
||||
:class="sidebar.state.value === 'collapsed' ? 'size-10' : 'w-full justify-between'"
|
||||
aria-label="Collections"
|
||||
title="Collections"
|
||||
>
|
||||
<template v-if="sidebar.state.value === 'collapsed'">
|
||||
<MdiHomeGroup class="size-5" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="flex items-center truncate">
|
||||
<span class="truncate">
|
||||
{{ selectedCollection && selectedCollection.name ? selectedCollection.name : "Select collection" }}
|
||||
</span>
|
||||
<span v-if="selectedCollection?.role" class="ml-2">
|
||||
<Badge class="whitespace-nowrap" :variant="roleVariant(selectedCollection?.role)">
|
||||
{{ selectedCollection?.role }}
|
||||
</Badge>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</template>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
:class="[sidebar.state.value === 'collapsed' ? 'min-w-48 p-0' : 'w-[--reka-popper-anchor-width] p-0']"
|
||||
>
|
||||
<Command :ignore-filter="true">
|
||||
<CommandGroup>
|
||||
<CommandItem as-child value="collection-settings">
|
||||
<NuxtLink to="/collection" class="flex w-full items-center">
|
||||
<Settings class="mr-2 size-4" />
|
||||
Collection Settings
|
||||
</NuxtLink>
|
||||
</CommandItem>
|
||||
<CommandItem value="create-collection" @select="() => {}">
|
||||
<Plus class="mr-2 size-4" /> Create New Collection
|
||||
</CommandItem>
|
||||
<CommandItem value="join-collection" @select="() => {}">
|
||||
<Plus class="mr-2 size-4" /> Join Existing Collection
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandInput v-model="search" placeholder="Search collections..." :display-value="_ => ''" />
|
||||
<CommandEmpty>No inventory found</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup heading="Your Collections">
|
||||
<CommandItem
|
||||
v-for="collection in filteredCollections"
|
||||
:key="collection.id"
|
||||
:value="collection.id"
|
||||
@select="selectCollection(collection)"
|
||||
>
|
||||
<Check :class="cn('mr-2 h-4 w-4', value === collection.id ? 'opacity-100' : 'opacity-0')" />
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
{{ collection.name }}
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge class="whitespace-nowrap" variant="outline">{{ collection.count }}</Badge>
|
||||
<Badge class="whitespace-nowrap" :variant="roleVariant(collection.role)">{{ collection.role }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronsUpDown, Plus, Settings } from "lucide-vue-next";
|
||||
import MdiHomeGroup from "~icons/mdi/home-group";
|
||||
import fuzzysort from "fuzzysort";
|
||||
import { api } from "~/mock/collections";
|
||||
import type { Collection as MockCollection, User as MockUser } from "~/mock/collections";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import { useSidebar } from "@/components/ui/sidebar/utils";
|
||||
|
||||
// api.getCollections returns collection objects augmented with `count` and `role` for the current user
|
||||
type CollectionSummary = MockCollection & { count: number; role: MockUser["collections"][number]["role"] };
|
||||
|
||||
type Props = {
|
||||
modelValue?: string | null;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const open = ref(false);
|
||||
const search = ref("");
|
||||
const value = useVModel(props, "modelValue", emit);
|
||||
|
||||
// Use shared mock collections data via fake api (for current user)
|
||||
const collectionsList = ref<CollectionSummary[]>(api.getCollections() as CollectionSummary[]);
|
||||
|
||||
function roleVariant(role: string | undefined) {
|
||||
if (role === "owner") return "default";
|
||||
if (role === "admin") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function selectCollection(collection: CollectionSummary) {
|
||||
if (value.value !== collection.id) {
|
||||
value.value = collection.id;
|
||||
console.log(collection);
|
||||
}
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
const selectedCollection = computed(() => {
|
||||
return collectionsList.value.find(o => o.id === value.value) ?? null;
|
||||
});
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
const filteredCollections = computed(() => {
|
||||
const filtered = fuzzysort.go(search.value, collectionsList.value, { key: "name", all: true }).map(i => i.obj);
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Reset search when value is cleared
|
||||
watch(
|
||||
() => value.value,
|
||||
() => {
|
||||
if (!value.value) {
|
||||
search.value = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -1,18 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { Primitive, type PrimitiveProps } from "reka-ui";
|
||||
import { type ButtonVariants, buttonVariants } from ".";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { Primitive, type PrimitiveProps } from "reka-ui";
|
||||
import { type ButtonVariants, buttonVariants } from ".";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"];
|
||||
size?: ButtonVariants["size"];
|
||||
class?: HTMLAttributes["class"];
|
||||
}
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"];
|
||||
size?: ButtonVariants["size"];
|
||||
class?: HTMLAttributes["class"];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
});
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
57
frontend/components/ui/calendar/Calendar.vue
Normal file
57
frontend/components/ui/calendar/Calendar.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarRoot, type CalendarRootEmits, type CalendarRootProps, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from '.'
|
||||
|
||||
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const emits = defineEmits<CalendarRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarRoot
|
||||
v-slot="{ grid, weekDays }"
|
||||
:class="cn('p-3', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<CalendarHeader>
|
||||
<CalendarPrevButton />
|
||||
<CalendarHeading />
|
||||
<CalendarNextButton />
|
||||
</CalendarHeader>
|
||||
|
||||
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
|
||||
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
|
||||
<CalendarGridHead>
|
||||
<CalendarGridRow>
|
||||
<CalendarHeadCell
|
||||
v-for="day in weekDays" :key="day"
|
||||
>
|
||||
{{ day }}
|
||||
</CalendarHeadCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridHead>
|
||||
<CalendarGridBody>
|
||||
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
|
||||
<CalendarCell
|
||||
v-for="weekDate in weekDates"
|
||||
:key="weekDate.toString()"
|
||||
:date="weekDate"
|
||||
>
|
||||
<CalendarCellTrigger
|
||||
:day="weekDate"
|
||||
:month="month.value"
|
||||
/>
|
||||
</CalendarCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridBody>
|
||||
</CalendarGrid>
|
||||
</div>
|
||||
</CalendarRoot>
|
||||
</template>
|
||||
21
frontend/components/ui/calendar/CalendarCell.vue
Normal file
21
frontend/components/ui/calendar/CalendarCell.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarCell, type CalendarCellProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCell
|
||||
:class="cn('relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCell>
|
||||
</template>
|
||||
35
frontend/components/ui/calendar/CalendarCellTrigger.vue
Normal file
35
frontend/components/ui/calendar/CalendarCellTrigger.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarCellTrigger
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal',
|
||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||
// Selected
|
||||
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
|
||||
// Disabled
|
||||
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||
// Unavailable
|
||||
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||
// Outside months
|
||||
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarCellTrigger>
|
||||
</template>
|
||||
21
frontend/components/ui/calendar/CalendarGrid.vue
Normal file
21
frontend/components/ui/calendar/CalendarGrid.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarGrid, type CalendarGridProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGrid
|
||||
:class="cn('w-full border-collapse space-y-1', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</CalendarGrid>
|
||||
</template>
|
||||
11
frontend/components/ui/calendar/CalendarGridBody.vue
Normal file
11
frontend/components/ui/calendar/CalendarGridBody.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarGridBody, type CalendarGridBodyProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<CalendarGridBodyProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridBody v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridBody>
|
||||
</template>
|
||||
11
frontend/components/ui/calendar/CalendarGridHead.vue
Normal file
11
frontend/components/ui/calendar/CalendarGridHead.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { CalendarGridHead, type CalendarGridHeadProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<CalendarGridHeadProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridHead v-bind="props">
|
||||
<slot />
|
||||
</CalendarGridHead>
|
||||
</template>
|
||||
18
frontend/components/ui/calendar/CalendarGridRow.vue
Normal file
18
frontend/components/ui/calendar/CalendarGridRow.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarGridRow, type CalendarGridRowProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarGridRow>
|
||||
</template>
|
||||
18
frontend/components/ui/calendar/CalendarHeadCell.vue
Normal file
18
frontend/components/ui/calendar/CalendarHeadCell.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarHeadCell, type CalendarHeadCellProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeadCell :class="cn('w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarHeadCell>
|
||||
</template>
|
||||
18
frontend/components/ui/calendar/CalendarHeader.vue
Normal file
18
frontend/components/ui/calendar/CalendarHeader.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarHeader, type CalendarHeaderProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</CalendarHeader>
|
||||
</template>
|
||||
28
frontend/components/ui/calendar/CalendarHeading.vue
Normal file
28
frontend/components/ui/calendar/CalendarHeading.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CalendarHeading, type CalendarHeadingProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
defineSlots<{
|
||||
default: (props: { headingValue: string }) => any
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarHeading
|
||||
v-slot="{ headingValue }"
|
||||
:class="cn('text-sm font-medium', props.class)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot :heading-value>
|
||||
{{ headingValue }}
|
||||
</slot>
|
||||
</CalendarHeading>
|
||||
</template>
|
||||
29
frontend/components/ui/calendar/CalendarNextButton.vue
Normal file
29
frontend/components/ui/calendar/CalendarNextButton.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarNext
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarNext>
|
||||
</template>
|
||||
29
frontend/components/ui/calendar/CalendarPrevButton.vue
Normal file
29
frontend/components/ui/calendar/CalendarPrevButton.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CalendarPrev
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarPrev>
|
||||
</template>
|
||||
12
frontend/components/ui/calendar/index.ts
Normal file
12
frontend/components/ui/calendar/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as Calendar } from './Calendar.vue'
|
||||
export { default as CalendarCell } from './CalendarCell.vue'
|
||||
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue'
|
||||
export { default as CalendarGrid } from './CalendarGrid.vue'
|
||||
export { default as CalendarGridBody } from './CalendarGridBody.vue'
|
||||
export { default as CalendarGridHead } from './CalendarGridHead.vue'
|
||||
export { default as CalendarGridRow } from './CalendarGridRow.vue'
|
||||
export { default as CalendarHeadCell } from './CalendarHeadCell.vue'
|
||||
export { default as CalendarHeader } from './CalendarHeader.vue'
|
||||
export { default as CalendarHeading } from './CalendarHeading.vue'
|
||||
export { default as CalendarNextButton } from './CalendarNextButton.vue'
|
||||
export { default as CalendarPrevButton } from './CalendarPrevButton.vue'
|
||||
@@ -1,7 +1,12 @@
|
||||
import { computed, type ComputedRef } from "vue";
|
||||
import { createContext } from "reka-ui";
|
||||
import { useMagicKeys, useActiveElement } from "@vueuse/core";
|
||||
import type { BarcodeProduct, ItemSummary, MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import type {
|
||||
BarcodeProduct,
|
||||
ItemSummary,
|
||||
MaintenanceEntry,
|
||||
MaintenanceEntryWithDetails,
|
||||
} from "~~/lib/api/types/data-contracts";
|
||||
|
||||
export enum DialogID {
|
||||
AttachmentEdit = "attachment-edit",
|
||||
@@ -24,6 +29,8 @@ export enum DialogID {
|
||||
PageQRCode = "page-qr-code",
|
||||
UpdateLabel = "update-label",
|
||||
UpdateLocation = "update-location",
|
||||
CreateInvite = "create-invite",
|
||||
EditUser = "edit-user",
|
||||
UpdateTemplate = "update-template",
|
||||
ItemChangeDetails = "item-table-updater",
|
||||
}
|
||||
@@ -51,6 +58,7 @@ export type DialogParamsMap = {
|
||||
attachmentId: string;
|
||||
};
|
||||
[DialogID.CreateItem]?: { product?: BarcodeProduct };
|
||||
[DialogID.EditUser]?: { userId?: string };
|
||||
[DialogID.ProductImport]?: { barcode?: string };
|
||||
[DialogID.EditMaintenance]:
|
||||
| { type: "create"; itemId: string | string[] }
|
||||
@@ -70,7 +78,9 @@ export type DialogParamsMap = {
|
||||
export type DialogResultMap = {
|
||||
[DialogID.ItemImage]?: { action: "delete"; id: string };
|
||||
[DialogID.EditMaintenance]?: boolean;
|
||||
[DialogID.CreateInvite]?: boolean;
|
||||
[DialogID.ItemChangeDetails]?: boolean;
|
||||
[DialogID.EditUser]?: boolean;
|
||||
};
|
||||
|
||||
/** Helpers to split IDs by requirement */
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<ModalConfirm />
|
||||
<OutdatedModal v-if="status" :status="status" />
|
||||
<ItemCreateModal />
|
||||
<CreateInviteModal />
|
||||
<LabelCreateModal />
|
||||
<LocationCreateModal />
|
||||
<ItemBarcodeModal />
|
||||
@@ -24,6 +25,8 @@
|
||||
<AppLogo />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<AppCollectionSelector v-model:model-value="selectedCollectionId" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
@@ -119,12 +122,23 @@
|
||||
}"
|
||||
>
|
||||
<div class="flex h-1/2 items-center gap-2 sm:h-auto">
|
||||
<SidebarTrigger variant="default" />
|
||||
<div>
|
||||
<SidebarTrigger variant="default" />
|
||||
</div>
|
||||
<!-- <div>
|
||||
<Button size="icon">
|
||||
<AppLogo class="size-8" />
|
||||
</Button>
|
||||
</div> -->
|
||||
<NuxtLink to="/home">
|
||||
<AppHeaderText class="h-6" />
|
||||
</NuxtLink>
|
||||
<!-- <AppOrgSelector v-model:model-value="selectedOrg" /> -->
|
||||
</div>
|
||||
<div class="sm:grow" />
|
||||
<!-- <div class="flex items-center">
|
||||
<AppOrgSelector v-model:model-value="selectedOrg" />
|
||||
</div> -->
|
||||
<div class="flex h-1/2 grow items-center justify-end gap-2 sm:h-auto">
|
||||
<Input
|
||||
v-model:model-value="search"
|
||||
@@ -220,11 +234,15 @@
|
||||
import LabelCreateModal from "~/components/Label/CreateModal.vue";
|
||||
import LocationCreateModal from "~/components/Location/CreateModal.vue";
|
||||
import ItemBarcodeModal from "~/components/Item/BarcodeModal.vue";
|
||||
import CreateInviteModal from "~/components/Admin/CreateInviteModal.vue";
|
||||
import AppQuickMenuModal from "~/components/App/QuickMenuModal.vue";
|
||||
import AppScannerModal from "~/components/App/ScannerModal.vue";
|
||||
import AppLogo from "~/components/App/Logo.vue";
|
||||
import AppHeaderDecor from "~/components/App/HeaderDecor.vue";
|
||||
import AppHeaderText from "~/components/App/HeaderText.vue";
|
||||
import AppCollectionSelector from "~/components/App/CollectionSelector.vue";
|
||||
|
||||
const selectedCollectionId = ref<string>("c1");
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const username = computed(() => authCtx.user?.name || "User");
|
||||
@@ -359,6 +377,13 @@
|
||||
name: computed(() => t("menu.tools")),
|
||||
to: "/tools",
|
||||
},
|
||||
{
|
||||
icon: MdiAccount,
|
||||
id: 7,
|
||||
active: computed(() => route.path === "/admin"),
|
||||
name: computed(() => t("menu.admin")),
|
||||
to: "/admin",
|
||||
},
|
||||
];
|
||||
|
||||
const quickMenuActions = reactive([
|
||||
|
||||
216
frontend/mock/collections.ts
Normal file
216
frontend/mock/collections.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
export type Collection = { id: string; name: string };
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
created_at?: string;
|
||||
role: "admin" | "user" | string;
|
||||
password_set?: boolean;
|
||||
oidc_set?: boolean;
|
||||
collections: {
|
||||
id: string;
|
||||
role: "owner" | "admin" | "editor" | "viewer";
|
||||
}[];
|
||||
};
|
||||
|
||||
export const collections: Collection[] = [
|
||||
{ id: "c1", name: "Personal Inventory" },
|
||||
{ id: "c2", name: "Office Equipment" },
|
||||
{ id: "c3", name: "Workshop Tools" },
|
||||
];
|
||||
|
||||
export const users: User[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Alice Admin",
|
||||
email: "alice@example.com",
|
||||
created_at: new Date(new Date().setFullYear(new Date().getFullYear() - 2)).toISOString(),
|
||||
role: "admin",
|
||||
password_set: true,
|
||||
collections: [
|
||||
{ id: collections[0]!.id, role: "owner" },
|
||||
{ id: collections[1]!.id, role: "admin" },
|
||||
{ id: collections[2]!.id, role: "editor" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Bob User",
|
||||
email: "bob@example.com",
|
||||
created_at: new Date(new Date().setFullYear(new Date().getFullYear() - 1)).toISOString(),
|
||||
role: "user",
|
||||
password_set: true,
|
||||
oidc_set: true,
|
||||
collections: [
|
||||
{ id: collections[1]!.id, role: "owner" },
|
||||
{ id: collections[2]!.id, role: "admin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Charlie",
|
||||
email: "charlie@example.com",
|
||||
created_at: new Date().toISOString(),
|
||||
role: "user",
|
||||
password_set: false,
|
||||
// collections[3] was out of range (only 0..2 exist). Use collections[2].
|
||||
collections: [{ id: collections[2]!.id, role: "owner" }],
|
||||
},
|
||||
];
|
||||
|
||||
export type Invite = {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
role?: "owner" | "admin" | "editor" | "viewer";
|
||||
created_at?: string;
|
||||
expires_at?: string;
|
||||
max_uses?: number;
|
||||
uses?: number;
|
||||
};
|
||||
|
||||
export const invites: Invite[] = [
|
||||
{
|
||||
id: "i1",
|
||||
collectionId: collections[0]!.id,
|
||||
role: "viewer",
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(new Date().setDate(new Date().getDate() + 7)).toISOString(),
|
||||
max_uses: 5,
|
||||
uses: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// Simple in-memory fake API operating on the above arrays.
|
||||
export const api = {
|
||||
// by is the person who is requesting the collections, include the number of members and their role
|
||||
getCollections(by: string = "1") {
|
||||
const user = users.find(u => u.id === by);
|
||||
if (!user) return [];
|
||||
return user.collections
|
||||
.map(c => {
|
||||
const collection = collections.find(col => col.id === c.id);
|
||||
if (!collection) return null;
|
||||
// find number of people with access to this collection
|
||||
const count = users.reduce((acc, u) => {
|
||||
const hasAccess = u.collections.some(uc => uc.id === collection.id);
|
||||
return acc + (hasAccess ? 1 : 0);
|
||||
}, 0);
|
||||
return {
|
||||
...collection,
|
||||
count,
|
||||
role: c.role,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
},
|
||||
getUsers(): User[] {
|
||||
return users;
|
||||
},
|
||||
getInvites(): Invite[] {
|
||||
return invites;
|
||||
},
|
||||
getUser(id: string) {
|
||||
return users.find(u => u.id === id);
|
||||
},
|
||||
addUser(input: Partial<User>) {
|
||||
const u: User = {
|
||||
id: input.id ?? String(Date.now()),
|
||||
name: input.name ?? "",
|
||||
email: input.email ?? "",
|
||||
role: input.role ?? "user",
|
||||
password_set: input.password_set ?? false,
|
||||
oidc_set: input.oidc_set ?? false,
|
||||
collections: input.collections ?? [],
|
||||
};
|
||||
users.unshift(u);
|
||||
return u;
|
||||
},
|
||||
updateUser(updated: User) {
|
||||
const idx = users.findIndex(u => u.id === updated.id);
|
||||
if (idx >= 0) users.splice(idx, 1, { ...updated });
|
||||
return updated;
|
||||
},
|
||||
deleteUser(id: string) {
|
||||
const idx = users.findIndex(u => u.id === id);
|
||||
if (idx >= 0) {
|
||||
users.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
addInvite(input: Partial<Invite>) {
|
||||
const inv: Invite = {
|
||||
id: input.id ?? `i${Date.now()}`,
|
||||
collectionId: input.collectionId ?? collections[0]!.id,
|
||||
role: input.role ?? "viewer",
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: input.expires_at ? input.expires_at : undefined,
|
||||
max_uses: input.max_uses ? input.max_uses : undefined,
|
||||
uses: 0,
|
||||
};
|
||||
invites.unshift(inv);
|
||||
return inv;
|
||||
},
|
||||
deleteInvite(id: string) {
|
||||
const idx = invites.findIndex(i => i.id === id);
|
||||
if (idx >= 0) invites.splice(idx, 1);
|
||||
return idx >= 0;
|
||||
},
|
||||
addCollection(input: Partial<Collection>) {
|
||||
const col: Collection = { id: input.id ?? `c${Date.now()}`, name: input.name ?? "New Collection" };
|
||||
collections.push(col);
|
||||
// add user[0] to collection
|
||||
users[0]!.collections.push({ id: col.id, role: "owner" });
|
||||
return col;
|
||||
},
|
||||
updateCollection(updated: Collection) {
|
||||
const idx = collections.findIndex(c => c.id === updated.id);
|
||||
if (idx >= 0) collections.splice(idx, 1, { ...updated });
|
||||
return updated;
|
||||
},
|
||||
addUserToCollection(userId: string, collectionId: string, role: "owner" | "admin" | "editor" | "viewer" = "viewer") {
|
||||
const u = users.find(x => x.id === userId);
|
||||
if (!u) return null;
|
||||
const exists = u.collections.find(c => c.id === collectionId);
|
||||
if (exists) {
|
||||
exists.role = role;
|
||||
return exists;
|
||||
}
|
||||
const mem = { id: collectionId, role } as { id: string; role: "owner" | "admin" | "editor" | "viewer" };
|
||||
u.collections.push(mem);
|
||||
return mem;
|
||||
},
|
||||
removeUserFromCollection(userId: string, collectionId: string) {
|
||||
const u = users.find(x => x.id === userId);
|
||||
if (!u) return false;
|
||||
const idx = u.collections.findIndex(c => c.id === collectionId);
|
||||
if (idx >= 0) {
|
||||
const wasOwner = u.collections[idx]!.role === "owner";
|
||||
u.collections.splice(idx, 1);
|
||||
// if removed owner, and no other owners exist for that collection, delete the collection
|
||||
if (wasOwner) {
|
||||
const stillOwner = users.some(other =>
|
||||
(other.collections ?? []).some(c => c.id === collectionId && c.role === "owner")
|
||||
);
|
||||
if (!stillOwner) {
|
||||
// remove collection
|
||||
const cidx = collections.findIndex(c => c.id === collectionId);
|
||||
if (cidx >= 0) collections.splice(cidx, 1);
|
||||
// remove membership from all users
|
||||
users.forEach(mu => {
|
||||
mu.collections = (mu.collections ?? []).filter(c => c.id !== collectionId);
|
||||
});
|
||||
// remove invites to that collection
|
||||
for (let i = invites.length - 1; i >= 0; i--) {
|
||||
if (invites[i]!.collectionId === collectionId) invites.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
export default { collections, users, invites, api };
|
||||
199
frontend/pages/admin.vue
Normal file
199
frontend/pages/admin.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<!-- TODO:
|
||||
- make collection on hover show role and colour based on role
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useConfirm } from "~/composables/use-confirm";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableEmpty } from "~/components/ui/table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import BaseContainer from "@/components/Base/Container.vue";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import MdiPencil from "~icons/mdi/pencil";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import MdiCheck from "~icons/mdi/check";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
// import MdiOpenInNew from "~icons/mdi/open-in-new";
|
||||
// Badge component for collections display
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import UserFormDialog from "@/components/Admin/UserFormDialog.vue";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
|
||||
import { api, type Collection as MockCollection, type User } from "~/mock/collections";
|
||||
|
||||
// api.getCollections returns collections augmented with `count` and the current user's `role`
|
||||
type CollectionSummary = MockCollection & { count: number; role: User["collections"][number]["role"] };
|
||||
|
||||
const collections = ref<CollectionSummary[]>(api.getCollections() as CollectionSummary[]);
|
||||
const users = ref<User[]>(api.getUsers());
|
||||
|
||||
const query = ref("");
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return users.value;
|
||||
return users.value.filter(u => {
|
||||
return `${u.name} ${u.email} ${u.role}`.toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
const confirm = useConfirm();
|
||||
const { t } = useI18n();
|
||||
|
||||
// editing state handled in dialog component; role toggle logic applied on save
|
||||
|
||||
// helper to compute auth type for display
|
||||
// authType removed — not used in the template
|
||||
|
||||
function authType(u: User) {
|
||||
const parts: string[] = [];
|
||||
if (u.password_set) parts.push("Password");
|
||||
if (u.oidc_subject) parts.push("OIDC");
|
||||
return parts.length ? parts.join(" & ") : "None";
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
openDialog(DialogID.EditUser, {
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
users.value = api.getUsers();
|
||||
collections.value = api.getCollections();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openEdit(u: User) {
|
||||
openDialog(DialogID.EditUser, {
|
||||
params: { userId: u.id },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
users.value = api.getUsers();
|
||||
collections.value = api.getCollections();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmDelete(u: User) {
|
||||
const { isCanceled } = await confirm.open({
|
||||
message: t("global.delete_confirm") + " " + `${u.name} (${u.email})?`,
|
||||
});
|
||||
if (isCanceled) return;
|
||||
|
||||
api.deleteUser(u.id);
|
||||
users.value = api.getUsers();
|
||||
// TODO: call backend API to delete user when available
|
||||
}
|
||||
|
||||
// no more toggleActive; active is not used
|
||||
|
||||
function collectionName(id: string) {
|
||||
const col = collections.value.find(c => c.id === id);
|
||||
return col ? col.name : id;
|
||||
}
|
||||
|
||||
function roleVariant(role: string | undefined) {
|
||||
if (role === "owner") return "default";
|
||||
if (role === "admin") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
// dialog handles editing state now via dialog provider
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseContainer class="flex flex-col gap-4">
|
||||
<BaseSectionHeader>
|
||||
<span>User Management</span>
|
||||
<div class="ml-auto">
|
||||
<Button @click="openAdd">Add User</Button>
|
||||
</div>
|
||||
</BaseSectionHeader>
|
||||
|
||||
<Card class="p-0">
|
||||
<Table class="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="min-w-[160px]">{{ t("global.name") }}</TableHead>
|
||||
<TableHead class="min-w-[220px]">{{ t("global.email") }}</TableHead>
|
||||
<TableHead class="min-w-[96px] text-center">Is Admin</TableHead>
|
||||
<TableHead class="min-w-[220px]">Collections</TableHead>
|
||||
<TableHead class="min-w-[96px] text-center">{{ t("global.details") }}</TableHead>
|
||||
<TableHead class="w-40 text-center"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<template v-if="filtered.length">
|
||||
<TableRow v-for="u in filtered" :key="u.id">
|
||||
<TableCell>{{ u.name }}</TableCell>
|
||||
<TableCell>{{ u.email }}</TableCell>
|
||||
<TableCell class="text-center align-middle">
|
||||
<div class="flex size-full items-center justify-center font-medium">
|
||||
<MdiCheck v-if="u.role === 'admin'" class="text-primary" />
|
||||
<MdiClose v-else class="text-destructive" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<template v-if="u.collections && u.collections.length">
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<template v-for="c in u.collections" :key="c.id">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Badge class="whitespace-nowrap" :variant="roleVariant(c.role)">{{
|
||||
collectionName(c.id)
|
||||
}}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p class="text-sm">{{ c.role }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center align-middle">
|
||||
<div class="flex size-full items-center justify-center">
|
||||
<span>{{ authType(u) }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-right align-middle">
|
||||
<div class="flex size-full items-center justify-end gap-2">
|
||||
<Button size="icon" variant="outline" class="size-8" :title="t('global.edit')" @click="openEdit(u)">
|
||||
<MdiPencil class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
class="size-8"
|
||||
:title="t('global.delete')"
|
||||
@click="confirmDelete(u)"
|
||||
>
|
||||
<MdiDelete class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<TableEmpty :colspan="6">
|
||||
<p>{{ $t("items.selector.no_results") }}</p>
|
||||
</TableEmpty>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<!-- Add / Edit form modal (moved to component) -->
|
||||
<UserFormDialog />
|
||||
</BaseContainer>
|
||||
</template>
|
||||
225
frontend/pages/collection.vue
Normal file
225
frontend/pages/collection.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { api, type User as MockUser, type Invite as MockInvite } from "~/mock/collections";
|
||||
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
// Popover removed from invite UI; no longer importing
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { Card } from "@/components/ui/card"; // Assuming you have a Card component
|
||||
import { Badge } from "@/components/ui/badge"; // Assuming you have a Badge component
|
||||
import { PlusCircle, Trash } from "lucide-vue-next"; // Icons
|
||||
import { format } from "date-fns";
|
||||
import CopyText from "@/components/global/CopyText.vue";
|
||||
import BaseContainer from "@/components/Base/Container.vue";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import { useDialog } from "~/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["auth"],
|
||||
});
|
||||
useHead({
|
||||
title: "HomeBox | " + t("menu.maintenance"),
|
||||
});
|
||||
|
||||
// Use centralized mock data / fake API
|
||||
const users = ref<MockUser[]>(api.getUsers());
|
||||
const invites = ref<MockInvite[]>(api.getInvites());
|
||||
|
||||
// Current collection context (this page shows a single collection)
|
||||
// For now use the first mock collection as the active collection
|
||||
const currentCollectionId = api.getCollections()[0]?.id ?? "";
|
||||
|
||||
// New invite email input
|
||||
// (declared below with invite inputs)
|
||||
|
||||
// Settings state
|
||||
const collectionName = ref<string>("Personal Inventory");
|
||||
const saved = ref(false);
|
||||
|
||||
// invite inputs (moved to dialog)
|
||||
|
||||
const page = ref(1);
|
||||
|
||||
const roles = ["owner", "admin", "editor", "viewer"];
|
||||
|
||||
function inviteUrl(code: string) {
|
||||
if (typeof window === "undefined") return "";
|
||||
return `${window.location.origin}?token=${code}`;
|
||||
}
|
||||
|
||||
function getMembershipRole(user: MockUser) {
|
||||
const mem = user.collections.find(c => c.id === currentCollectionId);
|
||||
return mem?.role ?? "viewer";
|
||||
}
|
||||
|
||||
function roleVariant(role: string) {
|
||||
return role === "owner" ? "default" : role === "admin" ? "secondary" : "outline";
|
||||
}
|
||||
|
||||
function handleRoleChange(userId: string, newRole: unknown) {
|
||||
// Update the role for this user specific to the current collection
|
||||
const roleStr = String(newRole || "viewer");
|
||||
api.addUserToCollection(userId, currentCollectionId, roleStr as MockUser["collections"][number]["role"]);
|
||||
users.value = api.getUsers();
|
||||
}
|
||||
|
||||
function handleRemoveUser(userId: string) {
|
||||
api.deleteUser(userId);
|
||||
users.value = api.getUsers();
|
||||
}
|
||||
|
||||
// Invite creation now handled by dialog component; keep helper removed.
|
||||
|
||||
function deleteInvite(inviteId: string) {
|
||||
api.deleteInvite(inviteId);
|
||||
invites.value = api.getInvites();
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
// Stub: persist settings to API when implemented
|
||||
console.log("Saving collection settings", collectionName.value);
|
||||
saved.value = true;
|
||||
setTimeout(() => (saved.value = false), 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BaseContainer class="flex flex-col gap-4">
|
||||
<BaseSectionHeader> Collection Settings </BaseSectionHeader>
|
||||
<ButtonGroup>
|
||||
<Button size="sm" :variant="page == 1 ? 'default' : 'outline'" @click="page = 1"> Users </Button>
|
||||
<Button size="sm" :variant="page == 2 ? 'default' : 'outline'" @click="page = 2"> Invites </Button>
|
||||
<Button size="sm" :variant="page == 3 ? 'default' : 'outline'" @click="page = 3"> Settings </Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<Card v-if="page == 1" class="">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="user in users" :key="user.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ user.name }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
:model-value="getMembershipRole(user)"
|
||||
@update:model-value="newRole => handleRoleChange(user.id, newRole)"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span class="flex items-center">
|
||||
<Badge class="whitespace-nowrap" :variant="roleVariant(getMembershipRole(user))">{{
|
||||
getMembershipRole(user)
|
||||
}}</Badge>
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="role in roles" :key="role" :value="role">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<Badge
|
||||
class="whitespace-nowrap"
|
||||
:variant="role === 'owner' ? 'default' : role === 'admin' ? 'secondary' : 'outline'"
|
||||
>
|
||||
{{ role }}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ (user as any).created_at ? format(new Date((user as any).created_at), "PPP") : "-" }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<div class="flex w-full items-center justify-end gap-2">
|
||||
<Button variant="destructive" size="icon" @click="handleRemoveUser(user.id)">
|
||||
<Trash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Card v-if="page == 2" class="p-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="text-lg font-semibold">Existing Invites</h3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Max Uses</TableHead>
|
||||
<TableHead>Uses</TableHead>
|
||||
<TableHead class="text-right"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="invite in invites" :key="invite.id">
|
||||
<TableCell class="font-medium">{{ invite.id }}</TableCell>
|
||||
<TableCell>{{ invite.expires_at ? format(new Date(invite.expires_at), "PPP") : "Never" }}</TableCell>
|
||||
<TableCell>{{ invite.max_uses ?? "∞" }}</TableCell>
|
||||
<TableCell>{{ invite.uses ?? 0 }}</TableCell>
|
||||
<TableCell class="w-max">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<CopyText :text="inviteUrl(invite.id)" />
|
||||
<Button variant="destructive" size="icon" @click="deleteInvite(invite.id)">
|
||||
<Trash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Create New Invite</h3>
|
||||
<div class="w-56">
|
||||
<Button
|
||||
class="w-full"
|
||||
@click="openDialog(DialogID.CreateInvite, { onClose: () => (invites.value = api.getInvites()) })"
|
||||
>
|
||||
<PlusCircle class="mr-2 size-4" /> Generate Invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card v-if="page == 3" class="p-4">
|
||||
<h3 class="text-lg font-semibold">Collection Settings</h3>
|
||||
|
||||
<div class="mt-4 grid items-end gap-4 md:grid-cols-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="collection-name">Name</Label>
|
||||
<Input id="collection-name" v-model="collectionName" placeholder="Collection name" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button class="w-full" @click="saveSettings">Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="saved" class="mt-3 text-sm text-green-600">Saved</p>
|
||||
</Card>
|
||||
</BaseContainer>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user