Compare commits

...

7 Commits

Author SHA1 Message Date
tonyaellie
dc7f06210c feat: progress 2025-12-27 22:24:38 +00:00
tonyaellie
89692b4603 feat: progress 2025-12-27 20:49:34 +00:00
tonyaellie
c444117a1d Merge branch 'tonya/collections' of https://github.com/sysadminsmedia/homebox into tonya/collections 2025-12-27 11:33:49 +00:00
tonyaellie
397aed47a8 feat: add very mock admin page 2025-12-27 11:28:30 +00:00
tonyaellie
9e8172657b feat: mock ui 2025-12-27 11:28:27 +00:00
tonyaellie
ab57085f8b feat: add very mock admin page 2025-12-27 11:19:09 +00:00
tonyaellie
12d6b17318 feat: mock ui 2025-06-02 12:08:29 +00:00
22 changed files with 1599 additions and 14 deletions

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'

View File

@@ -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 */

View File

@@ -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([

View 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
View 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>

View 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>