mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-27 15:41:42 +01:00
Compare commits
6 Commits
v0.22.2
...
tonya/coll
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c444117a1d | ||
|
|
397aed47a8 | ||
|
|
9e8172657b | ||
|
|
ab57085f8b | ||
|
|
473027c1ae | ||
|
|
12d6b17318 |
@@ -1,4 +1,6 @@
|
||||
-- +goose Up
|
||||
-- +goose no transaction
|
||||
PRAGMA foreign_keys=OFF;
|
||||
-- SQLite doesn't support ALTER COLUMN directly, so we need to recreate the table
|
||||
-- Create a temporary table with the new schema
|
||||
CREATE TABLE users_temp (
|
||||
@@ -29,4 +31,5 @@ DROP TABLE users;
|
||||
ALTER TABLE users_temp RENAME TO users;
|
||||
|
||||
-- Recreate the unique index
|
||||
CREATE UNIQUE INDEX users_email_key on users (email);
|
||||
CREATE UNIQUE INDEX users_email_key on users (email);
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
130
frontend/components/App/OrgSelector.vue
Normal file
130
frontend/components/App/OrgSelector.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
class="w-full justify-between"
|
||||
>
|
||||
{{ value && value.name ? value.name : "Select inventory" }}
|
||||
<div class="flex items-center gap-2" v-if="value">
|
||||
<Badge
|
||||
class="whitespace-nowrap"
|
||||
:variant="value.role === 'owner' ? 'default' : value.role === 'admin' ? 'secondary' : 'outline'"
|
||||
>
|
||||
{{ value.role }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[--reka-popper-anchor-width] p-0">
|
||||
<Command :ignore-filter="true">
|
||||
<CommandInput v-model="search" placeholder="Search collections..." :display-value="(_) => ''" />
|
||||
<CommandEmpty>No inventory found</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup heading="Your Collections">
|
||||
<CommandItem
|
||||
v-for="org in filteredOrgs"
|
||||
:key="org.id"
|
||||
:value="org.id"
|
||||
@select="selectOrg(org as unknown as OrgSummary)"
|
||||
>
|
||||
<Check :class="cn('mr-2 h-4 w-4', value?.id === org.id ? 'opacity-100' : 'opacity-0')" />
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
{{ org.name }}
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge
|
||||
class="whitespace-nowrap"
|
||||
variant="outline"
|
||||
>
|
||||
{{ org.count }}
|
||||
</Badge>
|
||||
<Badge
|
||||
class="whitespace-nowrap"
|
||||
:variant="org.role === 'owner' ? 'default' : org.role === 'admin' ? 'secondary' : 'outline'"
|
||||
>
|
||||
{{ org.role }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandGroup>
|
||||
<CommandItem @select="() => {}">
|
||||
<Plus class="mr-2 size-4" /> Create New Collection
|
||||
</CommandItem>
|
||||
<CommandItem @select="() => {}">
|
||||
<Plus class="mr-2 size-4" /> Join Existing Collection
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronsUpDown, Lock, Users, Plus } from "lucide-vue-next";
|
||||
import fuzzysort from "fuzzysort";
|
||||
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";
|
||||
|
||||
type OrgSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
role: "owner" | "admin" | "editor" | "viewer";
|
||||
type: "personal" | "org";
|
||||
};
|
||||
|
||||
type Props = {
|
||||
modelValue?: OrgSummary | null;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const open = ref(false);
|
||||
const search = ref("");
|
||||
const value = useVModel(props, "modelValue", emit);
|
||||
|
||||
// Mock data for demonstration purposes
|
||||
const orgs = ref<OrgSummary[]>([
|
||||
{ id: "1", name: "Personal Inventory", count: 1, role: "owner", type: "personal" },
|
||||
{ id: "2", name: "Family Home", count: 4, role: "admin", type: "org" },
|
||||
{ id: "3", name: "Office Equipment", count: 12, role: "editor", type: "org" },
|
||||
{ id: "4", name: "Workshop Tools", count: 3, role: "viewer", type: "org" },
|
||||
]);
|
||||
|
||||
function selectOrg(org: OrgSummary) {
|
||||
if (value.value?.id !== org.id) {
|
||||
value.value = org;
|
||||
} else {
|
||||
value.value = null;
|
||||
}
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
const filteredOrgs = computed(() => {
|
||||
const filtered = fuzzysort.go(search.value, orgs.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'
|
||||
@@ -24,6 +24,7 @@
|
||||
<AppLogo />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<!-- <AppOrgSelector v-model:model-value="selectedOrg" /> -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
@@ -85,6 +86,19 @@
|
||||
<span>{{ $t("menu.scanner") }}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
:class="{
|
||||
'text-nowrap': typeof locale === 'string' && locale.startsWith('zh-'),
|
||||
}"
|
||||
:tooltip="$t('menu.scanner')"
|
||||
@click.prevent="openDialog('scanner')"
|
||||
>
|
||||
<MdiAccount />
|
||||
<span>Collection Settings</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
@@ -119,12 +133,24 @@
|
||||
}"
|
||||
>
|
||||
<div class="flex h-1/2 items-center gap-2 sm:h-auto">
|
||||
<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"
|
||||
@@ -226,6 +252,8 @@
|
||||
import AppHeaderDecor from "~/components/App/HeaderDecor.vue";
|
||||
import AppHeaderText from "~/components/App/HeaderText.vue";
|
||||
|
||||
const selectedOrg = ref<any>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const username = computed(() => authCtx.user?.name || "User");
|
||||
|
||||
@@ -345,13 +373,13 @@
|
||||
name: computed(() => t("menu.maintenance")),
|
||||
to: "/maintenance",
|
||||
},
|
||||
{
|
||||
icon: MdiAccount,
|
||||
id: 5,
|
||||
active: computed(() => route.path === "/profile"),
|
||||
name: computed(() => t("menu.profile")),
|
||||
to: "/profile",
|
||||
},
|
||||
// {
|
||||
// icon: MdiAccount,
|
||||
// id: 5,
|
||||
// active: computed(() => route.path === "/profile"),
|
||||
// name: computed(() => t("menu.profile")),
|
||||
// to: "/profile",
|
||||
// },
|
||||
{
|
||||
icon: MdiCog,
|
||||
id: 6,
|
||||
@@ -406,6 +434,36 @@
|
||||
const authCtx = useAuthContext();
|
||||
const api = useUserApi();
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
// await api.user.self();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
// if (!authCtx.isAuthorized()) {
|
||||
// console.log("Not authorised, redirecting to login");
|
||||
// await navigateTo("/");
|
||||
// }
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
checkAuth();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
await authCtx.logout(api);
|
||||
navigateTo("/");
|
||||
|
||||
260
frontend/pages/admin.vue
Normal file
260
frontend/pages/admin.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<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 MdiPencil from "~icons/mdi/pencil";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import MdiAccountMultiple from "~icons/mdi/account-multiple";
|
||||
import MdiOpenInNew from "~icons/mdi/open-in-new";
|
||||
import MdiCheck from "~icons/mdi/check";
|
||||
|
||||
type Group = { id: string; name: string; ownerName?: string };
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: "admin" | "user" | string;
|
||||
// password_set indicates whether the user has a local password
|
||||
password_set?: boolean;
|
||||
group?: Group | null;
|
||||
oidc_subject?: string | null;
|
||||
oidc_issuer?: string | null;
|
||||
};
|
||||
|
||||
// Mock groups (group.name is the owner's name per your request)
|
||||
const groups = ref<Group[]>([
|
||||
{ id: "g1", name: "Alice Admin" },
|
||||
{ id: "g2", name: "Owner Two" },
|
||||
]);
|
||||
|
||||
const users = ref<User[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "Alice Admin",
|
||||
email: "alice@example.com",
|
||||
role: "admin",
|
||||
password_set: true,
|
||||
group: groups.value[0],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Bob User",
|
||||
email: "bob@example.com",
|
||||
role: "user",
|
||||
password_set: true,
|
||||
group: groups.value[0],
|
||||
oidc_subject: "bob-sub",
|
||||
oidc_issuer: "https://oidc.example.com",
|
||||
},
|
||||
{ id: "3", name: "Charlie", email: "charlie@example.com", role: "user", password_set: false, group: null },
|
||||
]);
|
||||
|
||||
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 editing = ref<User | null>(null);
|
||||
const showForm = ref(false);
|
||||
const newPassword = ref("");
|
||||
const editingGroupId = ref<string | null>(null);
|
||||
const confirm = useConfirm();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isEditingExisting = computed(() => editing.value !== null && users.value.some(u => u.id === editing.value!.id));
|
||||
|
||||
const editingIsAdmin = computed({
|
||||
get: () => editing.value?.role === "admin",
|
||||
set: (v: boolean) => {
|
||||
if (!editing.value) return;
|
||||
editing.value.role = v ? "admin" : "user";
|
||||
},
|
||||
});
|
||||
|
||||
// helper to compute auth type for display
|
||||
// authType removed — not used in the template
|
||||
|
||||
function openAdd() {
|
||||
editing.value = { id: String(Date.now()), name: "", email: "", role: "user", password_set: false, group: null };
|
||||
newPassword.value = "";
|
||||
editingGroupId.value = null;
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function openEdit(u: User) {
|
||||
editing.value = { ...u };
|
||||
editingGroupId.value = u.group?.id ?? null;
|
||||
newPassword.value = "";
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function saveUser() {
|
||||
if (!editing.value) return;
|
||||
// basic validation
|
||||
if (!editing.value.name.trim() || !editing.value.email.trim()) {
|
||||
// keep UX simple: alert for now
|
||||
// Replace with a nicer notification component when available
|
||||
alert("Name and email are required");
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = users.value.findIndex(x => x.id === editing.value!.id);
|
||||
if (idx >= 0) {
|
||||
// apply password flag if new password was set locally
|
||||
if (newPassword.value && editing.value) editing.value.password_set = true;
|
||||
// apply group selection object
|
||||
if (editing.value) {
|
||||
editing.value.group = groups.value.find(g => g.id === editingGroupId.value) ?? null;
|
||||
}
|
||||
users.value.splice(idx, 1, { ...editing.value });
|
||||
} else {
|
||||
if (newPassword.value && editing.value) editing.value.password_set = true;
|
||||
if (editing.value) editing.value.group = groups.value.find(g => g.id === editingGroupId.value) ?? null;
|
||||
users.value.unshift({ ...editing.value });
|
||||
}
|
||||
|
||||
editing.value = null;
|
||||
showForm.value = false;
|
||||
// TODO: call backend API to persist changes when available
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
editing.value = null;
|
||||
showForm.value = false;
|
||||
}
|
||||
|
||||
async function confirmDelete(u: User) {
|
||||
const { isCanceled } = await confirm.open({
|
||||
message: t("global.delete_confirm") + " " + `${u.name} (${u.email})?`,
|
||||
});
|
||||
if (isCanceled) return;
|
||||
|
||||
users.value = users.value.filter(x => x.id !== u.id);
|
||||
// TODO: call backend API to delete user when available
|
||||
}
|
||||
|
||||
// no more toggleActive; active is not used
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-6xl p-6">
|
||||
<header class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">{{ t("global.details") }} - Administration</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<input v-model="query" :placeholder="t('global.search')" class="rounded border px-3 py-2" />
|
||||
<Button @click="openAdd">{{ t("global.add") }}</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<Table class="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{{ t("global.name") }}</TableHead>
|
||||
<TableHead>{{ t("global.email") }}</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Group</TableHead>
|
||||
<TableHead class="w-32 text-center">Auth</TableHead>
|
||||
<TableHead class="w-40 text-center">{{ t("global.details") }}</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="flex items-center gap-2">
|
||||
<MdiCheck v-if="u.role === 'admin'" class="size-4 text-green-600" />
|
||||
<span v-if="u.role === 'admin'">admin</span>
|
||||
<span v-else>-</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiAccountMultiple class="size-4" />
|
||||
<span>{{ u.group?.name ?? "-" }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<span v-if="u.oidc_subject" :title="u.oidc_issuer || u.oidc_subject">
|
||||
<MdiOpenInNew class="inline-block size-4" />
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<div class="flex 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>
|
||||
</section>
|
||||
|
||||
<!-- Add / Edit form modal (simple) -->
|
||||
<div v-if="showForm" class="fixed inset-0 z-40 flex items-center justify-center bg-black/40">
|
||||
<div class="w-full max-w-md rounded bg-white p-6 shadow-lg">
|
||||
<h2 class="mb-4 text-lg font-medium">{{ isEditingExisting ? t("global.edit") : t("global.add") }}</h2>
|
||||
<div class="space-y-3">
|
||||
<label class="block">
|
||||
<div class="mb-1 text-sm">{{ t("global.name") }}</div>
|
||||
<input v-model="editing!.name" class="w-full rounded border px-3 py-2" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<div class="mb-1 text-sm">{{ t("global.email") }}</div>
|
||||
<input v-model="editing!.email" class="w-full rounded border px-3 py-2" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="editingIsAdmin" type="checkbox" />
|
||||
<span class="text-sm">Admin</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<div class="mb-1 text-sm">Password</div>
|
||||
<input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
placeholder="Leave blank to keep"
|
||||
class="w-full rounded border px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<div class="mb-1 text-sm">Group</div>
|
||||
<select v-model="editingGroupId" class="w-full rounded border px-3 py-2">
|
||||
<option :value="null">-</option>
|
||||
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<Button variant="outline" @click="cancelForm">{{ t("global.cancel") }}</Button>
|
||||
<Button @click="saveUser">{{ t("global.save") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
362
frontend/pages/collection.vue
Normal file
362
frontend/pages/collection.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { v4 as uuidv4 } from 'uuid'; // For generating unique invite IDs
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 {
|
||||
Calendar as CalendarIcon,
|
||||
PlusCircle,
|
||||
Trash,
|
||||
} from 'lucide-vue-next'; // Icons
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth'],
|
||||
});
|
||||
useHead({
|
||||
title: 'HomeBox | ' + t('menu.maintenance'),
|
||||
});
|
||||
|
||||
interface User {
|
||||
username: string;
|
||||
id: string;
|
||||
role: 'owner' | 'admin' | 'editor' | 'viewer';
|
||||
lastActive: string;
|
||||
added: string;
|
||||
}
|
||||
|
||||
interface Invite {
|
||||
id: string;
|
||||
code: string;
|
||||
expiresAt: Date | null;
|
||||
maxUses: number | null;
|
||||
uses: number;
|
||||
}
|
||||
|
||||
const users = ref<User[]>([
|
||||
{
|
||||
username: 'tonya',
|
||||
id: '1',
|
||||
role: 'owner',
|
||||
lastActive: '12 hours ago',
|
||||
added: '13 hours ago',
|
||||
},
|
||||
{
|
||||
username: 'steve',
|
||||
id: '2',
|
||||
role: 'admin',
|
||||
lastActive: '1 day ago',
|
||||
added: '2 days ago',
|
||||
},
|
||||
{
|
||||
username: 'bob',
|
||||
id: '3',
|
||||
role: 'editor',
|
||||
lastActive: '30 minutes ago',
|
||||
added: '5 hours ago',
|
||||
},
|
||||
{
|
||||
username: 'john',
|
||||
id: '4',
|
||||
role: 'viewer',
|
||||
lastActive: '2 hours ago',
|
||||
added: '1 day ago',
|
||||
},
|
||||
]);
|
||||
|
||||
const invites = ref<Invite[]>([
|
||||
{
|
||||
id: uuidv4(),
|
||||
code: 'ABCDEF',
|
||||
expiresAt: null,
|
||||
maxUses: null,
|
||||
uses: 0,
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
code: 'GHIJKL',
|
||||
expiresAt: new Date(new Date().setDate(new Date().getDate() + 7)), // Expires in 7 days
|
||||
maxUses: 5,
|
||||
uses: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
const newInviteExpiresAt = ref<Date | null>(null);
|
||||
const newInviteMaxUses = ref<number | null>(null);
|
||||
|
||||
const page = ref(1);
|
||||
|
||||
const roles = ['owner', 'admin', 'editor', 'viewer'];
|
||||
|
||||
function handleRoleChange(userId: string, newRole: string) {
|
||||
const userIndex = users.value.findIndex((user) => user.id === userId);
|
||||
if (userIndex !== -1) {
|
||||
users.value[userIndex].role = newRole as
|
||||
| 'owner'
|
||||
| 'admin'
|
||||
| 'editor'
|
||||
| 'viewer';
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveUser(userId: string) {
|
||||
users.value = users.value.filter((user) => user.id !== userId);
|
||||
}
|
||||
|
||||
function generateInviteCode() {
|
||||
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
}
|
||||
|
||||
function createNewInvite() {
|
||||
const newInvite: Invite = {
|
||||
id: uuidv4(),
|
||||
code: generateInviteCode(),
|
||||
expiresAt: newInviteExpiresAt.value,
|
||||
maxUses: newInviteMaxUses.value,
|
||||
uses: 0,
|
||||
};
|
||||
invites.value.push(newInvite);
|
||||
newInviteExpiresAt.value = null;
|
||||
newInviteMaxUses.value = null;
|
||||
}
|
||||
|
||||
function deleteInvite(inviteId: string) {
|
||||
invites.value = invites.value.filter((invite) => invite.id !== inviteId);
|
||||
}
|
||||
</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="p-4 m-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Last Active</TableHead>
|
||||
<TableHead>Added</TableHead>
|
||||
<TableHead class="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="user in users" :key="user.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ user.username }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
:variant="
|
||||
user.role === 'owner'
|
||||
? 'default'
|
||||
: user.role === 'admin'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
"
|
||||
>
|
||||
{{ user.role }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{{ user.lastActive }}</TableCell>
|
||||
<TableCell>{{ user.added }}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button size="sm" variant="outline"> Edit </Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48">
|
||||
<div class="grid gap-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium leading-none">Edit User</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ user.username }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="role">Role</Label>
|
||||
<Select
|
||||
:model-value="user.role"
|
||||
@update:model-value="
|
||||
(newRole) => handleRoleChange(user.id, newRole)
|
||||
"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="role in roles"
|
||||
:key="role"
|
||||
:value="role"
|
||||
>
|
||||
{{ role }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@click="handleRemoveUser(user.id)"
|
||||
>
|
||||
Remove User
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Card v-if="page == 2" class="p-4 m-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">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="invite in invites" :key="invite.id">
|
||||
<TableCell class="font-medium">
|
||||
{{ invite.code }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{
|
||||
invite.expiresAt
|
||||
? format(invite.expiresAt, 'PPP')
|
||||
: 'Never'
|
||||
}}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ invite.maxUses !== null ? invite.maxUses : 'Unlimited' }}
|
||||
</TableCell>
|
||||
<TableCell>{{ invite.uses }}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
@click="deleteInvite(invite.id)"
|
||||
>
|
||||
<Trash class="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<h3 class="text-lg font-semibold">Create New Invite</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="new-invite-max-uses">Max Uses (optional)</Label>
|
||||
<Input
|
||||
id="new-invite-max-uses"
|
||||
type="number"
|
||||
v-model.number="newInviteMaxUses"
|
||||
placeholder="Unlimited"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="new-invite-expires-at">Expires At (optional)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full justify-start text-left font-normal"
|
||||
:class="
|
||||
!newInviteExpiresAt && 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{{
|
||||
newInviteExpiresAt
|
||||
? format(newInviteExpiresAt, 'PPP')
|
||||
: 'Pick a date'
|
||||
}}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Calendar v-model:model-value="newInviteExpiresAt" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<Button @click="createNewInvite" class="w-full">
|
||||
<PlusCircle class="mr-2 w-4 h-4" /> Generate Invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card v-if="page == 3" class="p-4 m-4">
|
||||
<h3 class="text-lg font-semibold">Collection Settings</h3>
|
||||
<p class="text-muted-foreground">
|
||||
This is where you would configure general collection settings.
|
||||
</p>
|
||||
<!-- Add your settings forms/components here -->
|
||||
</Card>
|
||||
</BaseContainer>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user