minor: new nav system (#5)
All checks were successful
ci/woodpecker/push/push Pipeline was successful
All checks were successful
ci/woodpecker/push/push Pipeline was successful
Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
<NuxtPage :key="$route.path" />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
.drawer-backdrop-enter-active, .drawer-backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.modal-enter-from, .modal-leave-to,
|
||||
.drawer-backdrop-enter-from, .drawer-backdrop-leave-to { opacity: 0; }
|
||||
.dropdown-enter-active, .dropdown-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
|
||||
.dropdown-enter-from, .dropdown-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
.modal-enter-active .relative, .modal-leave-active .relative { transition: transform 0.2s ease; }
|
||||
.modal-enter-from .relative, .modal-leave-to .relative { transform: scale(0.96); }
|
||||
|
||||
@@ -36,6 +38,10 @@
|
||||
.kestrel-map-container {
|
||||
background: #000 !important;
|
||||
}
|
||||
.kestrel-map-container .leaflet-container {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
.kestrel-map-container .leaflet-tile-pane,
|
||||
.kestrel-map-container .leaflet-map-pane,
|
||||
.kestrel-map-container .leaflet-tile-container {
|
||||
|
||||
115
app/components/AddUserModal.vue
Normal file
115
app/components/AddUserModal.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="show"
|
||||
aria-labelledby="add-user-title"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="kestrel-card-modal w-full max-w-sm p-4">
|
||||
<h3
|
||||
id="add-user-title"
|
||||
class="mb-3 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Add user
|
||||
</h3>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-identifier"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Username</label>
|
||||
<input
|
||||
id="add-identifier"
|
||||
v-model="form.identifier"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="kestrel-input"
|
||||
placeholder="username"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-password"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Password</label>
|
||||
<input
|
||||
id="add-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="kestrel-input"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-role"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Role</label>
|
||||
<select
|
||||
id="add-role"
|
||||
v-model="form.role"
|
||||
class="kestrel-input"
|
||||
>
|
||||
<option value="member">
|
||||
member
|
||||
</option>
|
||||
<option value="leader">
|
||||
leader
|
||||
</option>
|
||||
<option value="admin">
|
||||
admin
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
v-if="submitError"
|
||||
class="mb-2 text-xs text-red-400"
|
||||
>
|
||||
{{ submitError }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
>
|
||||
Add user
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
submitError: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'submit'])
|
||||
|
||||
const form = ref({ identifier: '', password: '', role: 'member' })
|
||||
|
||||
watch(() => props.show, (show) => {
|
||||
if (show) form.value = { identifier: '', password: '', role: 'member' }
|
||||
})
|
||||
|
||||
function onSubmit() {
|
||||
emit('submit', {
|
||||
identifier: form.value.identifier.trim(),
|
||||
password: form.value.password,
|
||||
role: form.value.role,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
95
app/components/AppDropdown.vue
Normal file
95
app/components/AppDropdown.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div ref="triggerRef">
|
||||
<slot />
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="teleport"
|
||||
to="body"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="open && placement"
|
||||
ref="menuRef"
|
||||
role="menu"
|
||||
class="fixed z-[100] min-w-[6rem] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-dropdown"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<slot name="menu" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<Transition
|
||||
v-else
|
||||
name="dropdown"
|
||||
>
|
||||
<div
|
||||
v-if="open"
|
||||
ref="menuRef"
|
||||
role="menu"
|
||||
class="absolute right-0 top-full z-[2001] mt-1 min-w-[160px] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow"
|
||||
>
|
||||
<slot name="menu" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
teleport: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const triggerRef = ref(null)
|
||||
const menuRef = ref(null)
|
||||
const placement = ref(null)
|
||||
const menuStyle = computed(() => {
|
||||
if (!placement.value) return undefined
|
||||
const p = placement.value
|
||||
return { top: p.top + 'px', left: p.left + 'px', minWidth: p.minWidth + 'px' }
|
||||
})
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (open && triggerRef.value && props.teleport) {
|
||||
nextTick(() => {
|
||||
const rect = triggerRef.value.getBoundingClientRect()
|
||||
placement.value = {
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
minWidth: Math.max(rect.width, 96),
|
||||
}
|
||||
})
|
||||
}
|
||||
else {
|
||||
placement.value = null
|
||||
}
|
||||
})
|
||||
|
||||
function onDocumentClick(e) {
|
||||
if (!props.open) return
|
||||
const trigger = triggerRef.value
|
||||
const menu = menuRef.value
|
||||
const inTrigger = trigger && trigger.contains(e.target)
|
||||
const inMenu = menu && menu.contains(e.target)
|
||||
if (!inTrigger && !inMenu) emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
</script>
|
||||
89
app/components/AppShell.vue
Normal file
89
app/components/AppShell.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<header class="relative z-40 flex h-14 shrink-0 items-center gap-3 bg-kestrel-surface px-4">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="text-lg font-semibold tracking-wide text-kestrel-text no-underline text-shadow-glow-md transition-colors hover:text-kestrel-accent focus-visible:ring-2 focus-visible:ring-kestrel-accent focus-visible:rounded"
|
||||
>
|
||||
KestrelOS
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent md:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
:aria-expanded="drawerOpen"
|
||||
@click="drawerOpen = !drawerOpen"
|
||||
>
|
||||
<span
|
||||
class="text-lg leading-none"
|
||||
aria-hidden="true"
|
||||
>☰</span>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<div class="flex items-center gap-2">
|
||||
<UserMenu
|
||||
v-if="user"
|
||||
:user="user"
|
||||
@signout="onLogout"
|
||||
/>
|
||||
<NuxtLink
|
||||
v-else
|
||||
to="/login"
|
||||
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
>
|
||||
Sign in
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<NavDrawer
|
||||
v-model="drawerOpen"
|
||||
v-model:collapsed="sidebarCollapsed"
|
||||
:is-mobile="isMobile"
|
||||
/>
|
||||
<!-- Content area: rounded top-left so it nestles into the shell (GitLab gl-rounded-t-lg style). -->
|
||||
<div class="relative min-h-0 flex-1 min-w-0 overflow-clip rounded-tl-lg">
|
||||
<main class="relative h-full w-full min-h-0 overflow-auto">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||
const drawerOpen = ref(true)
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'kestrelos-sidebar-collapsed'
|
||||
const sidebarCollapsed = ref(false)
|
||||
onMounted(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY)
|
||||
if (stored !== null) sidebarCollapsed.value = stored === 'true'
|
||||
}
|
||||
catch {
|
||||
// localStorage unavailable (e.g. private mode)
|
||||
}
|
||||
})
|
||||
watch(sidebarCollapsed, (v) => {
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(v))
|
||||
}
|
||||
catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
})
|
||||
|
||||
const { user, refresh } = useUser()
|
||||
|
||||
watch(isMobile, (mobile) => {
|
||||
if (mobile) drawerOpen.value = false
|
||||
}, { immediate: true })
|
||||
|
||||
async function onLogout() {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
await refresh()
|
||||
await navigateTo('/')
|
||||
}
|
||||
</script>
|
||||
36
app/components/BaseModal.vue
Normal file
36
app/components/BaseModal.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="ariaLabelledby"
|
||||
@keydown.escape="$emit('close')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/60 transition-opacity"
|
||||
aria-label="Close"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
<div
|
||||
class="relative w-full"
|
||||
@click.stop
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
show: Boolean,
|
||||
ariaLabelledby: { type: String, default: undefined },
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
46
app/components/DeleteUserConfirmModal.vue
Normal file
46
app/components/DeleteUserConfirmModal.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="!!user"
|
||||
aria-labelledby="delete-user-title"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div
|
||||
v-if="user"
|
||||
class="kestrel-card-modal w-full max-w-sm p-4"
|
||||
>
|
||||
<h3
|
||||
id="delete-user-title"
|
||||
class="mb-2 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Delete user?
|
||||
</h3>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
Are you sure you want to delete <strong class="text-kestrel-text">{{ user.identifier }}</strong>? They will not be able to sign in again.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-red-500/60 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/20"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
user: { type: Object, default: null },
|
||||
})
|
||||
|
||||
defineEmits(['close', 'confirm'])
|
||||
</script>
|
||||
95
app/components/EditUserModal.vue
Normal file
95
app/components/EditUserModal.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="!!user"
|
||||
aria-labelledby="edit-user-title"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div
|
||||
v-if="user"
|
||||
class="kestrel-card-modal w-full max-w-sm p-4"
|
||||
>
|
||||
<h3
|
||||
id="edit-user-title"
|
||||
class="mb-3 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Edit local user
|
||||
</h3>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="edit-identifier"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Identifier</label>
|
||||
<input
|
||||
id="edit-identifier"
|
||||
v-model="form.identifier"
|
||||
type="text"
|
||||
required
|
||||
class="kestrel-input"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<label
|
||||
for="edit-password"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>New password (leave blank to keep)</label>
|
||||
<input
|
||||
id="edit-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="kestrel-input"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
<p class="mt-0.5 text-xs text-kestrel-muted">
|
||||
If you change your password, use the new one next time you sign in.
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="submitError"
|
||||
class="mb-2 text-xs text-red-400"
|
||||
>
|
||||
{{ submitError }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
user: { type: Object, default: null },
|
||||
submitError: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'submit'])
|
||||
|
||||
const form = ref({ identifier: '', password: '' })
|
||||
|
||||
watch(() => props.user, (u) => {
|
||||
if (u) form.value = { identifier: u.identifier, password: '' }
|
||||
}, { immediate: true })
|
||||
|
||||
function onSubmit() {
|
||||
const payload = { identifier: form.value.identifier.trim() }
|
||||
if (form.value.password) payload.password = form.value.password
|
||||
emit('submit', payload)
|
||||
}
|
||||
</script>
|
||||
@@ -201,6 +201,7 @@ function createMap(initialCenter) {
|
||||
updateMarkers()
|
||||
updatePoiMarkers()
|
||||
updateLiveMarkers()
|
||||
nextTick(() => map.invalidateSize())
|
||||
}
|
||||
|
||||
function updateMarkers() {
|
||||
@@ -403,6 +404,8 @@ function initMapWithLocation() {
|
||||
)
|
||||
}
|
||||
|
||||
let resizeObserver = null
|
||||
|
||||
onMounted(async () => {
|
||||
if (!import.meta.client || typeof document === 'undefined') return
|
||||
const [leaflet, offline] = await Promise.all([
|
||||
@@ -422,6 +425,15 @@ onMounted(async () => {
|
||||
leafletRef.value = { L, offlineApi: offline }
|
||||
initMapWithLocation()
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
|
||||
nextTick(() => {
|
||||
if (mapRef.value) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
mapContext.value?.map?.invalidateSize()
|
||||
})
|
||||
resizeObserver.observe(mapRef.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function onDocumentClick(e) {
|
||||
@@ -430,6 +442,10 @@ function onDocumentClick(e) {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
if (resizeObserver && mapRef.value) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
destroyMap()
|
||||
})
|
||||
|
||||
|
||||
133
app/components/MembersTable.vue
Normal file
133
app/components/MembersTable.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="overflow-x-auto rounded border border-kestrel-border">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Identifier
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Auth
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-2 font-medium text-kestrel-text"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
class="border-b border-kestrel-border"
|
||||
>
|
||||
<td class="px-4 py-2 text-kestrel-text">
|
||||
{{ u.identifier }}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<span
|
||||
class="rounded px-1.5 py-0.5 text-xs text-kestrel-muted"
|
||||
:class="u.auth_provider === 'oidc' ? 'bg-kestrel-surface' : ''"
|
||||
>
|
||||
{{ u.auth_provider === 'oidc' ? 'OIDC' : 'Local' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<AppDropdown
|
||||
v-if="isAdmin"
|
||||
:open="openRoleDropdownId === u.id"
|
||||
teleport
|
||||
@close="emit('closeRoleDropdown')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-[6rem] items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-left text-sm text-kestrel-text shadow-sm transition-colors hover:border-kestrel-accent/50 hover:bg-kestrel-surface"
|
||||
:aria-expanded="openRoleDropdownId === u.id"
|
||||
:aria-haspopup="true"
|
||||
aria-label="Change role"
|
||||
@click.stop="emit('toggleRoleDropdown', u.id)"
|
||||
>
|
||||
<span>{{ roleByUserId[u.id] ?? u.role }}</span>
|
||||
<span
|
||||
class="text-kestrel-muted transition-transform"
|
||||
:class="openRoleDropdownId === u.id && 'rotate-180'"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
<template #menu>
|
||||
<button
|
||||
v-for="role in roleOptions"
|
||||
:key="role"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm transition-colors"
|
||||
:class="roleByUserId[u.id] === role
|
||||
? 'bg-kestrel-accent-dim text-kestrel-accent'
|
||||
: 'text-kestrel-text hover:bg-kestrel-border hover:text-kestrel-text'"
|
||||
@click.stop="emit('selectRole', u.id, role)"
|
||||
>
|
||||
{{ role }}
|
||||
</button>
|
||||
</template>
|
||||
</AppDropdown>
|
||||
<span
|
||||
v-else
|
||||
class="text-kestrel-muted"
|
||||
>{{ u.role }}</span>
|
||||
</td>
|
||||
<td
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-if="roleByUserId[u.id] !== u.role"
|
||||
type="button"
|
||||
class="rounded border border-kestrel-accent px-2 py-1 text-xs text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
@click="emit('saveRole', u.id)"
|
||||
>
|
||||
Save role
|
||||
</button>
|
||||
<template v-if="u.auth_provider !== 'oidc'">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-2 py-1 text-xs text-kestrel-text hover:bg-kestrel-surface"
|
||||
@click="emit('editUser', u)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
v-if="u.id !== currentUserId"
|
||||
type="button"
|
||||
class="rounded border border-red-500/60 px-2 py-1 text-xs text-red-400 hover:bg-red-500/10"
|
||||
@click="emit('deleteConfirm', u)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
users: { type: Array, required: true },
|
||||
roleByUserId: { type: Object, required: true },
|
||||
roleOptions: { type: Array, required: true },
|
||||
isAdmin: Boolean,
|
||||
currentUserId: { type: [String, Number], default: null },
|
||||
openRoleDropdownId: { type: [String, Number], default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggleRoleDropdown', 'closeRoleDropdown', 'selectRole', 'saveRole', 'editUser', 'deleteConfirm'])
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="flex h-full shrink-0">
|
||||
<Transition name="drawer-backdrop">
|
||||
<button
|
||||
v-if="modelValue"
|
||||
v-if="isMobile && modelValue"
|
||||
type="button"
|
||||
class="fixed inset-0 z-20 block h-full w-full border-0 bg-black/50 p-0 md:hidden"
|
||||
aria-label="Close navigation"
|
||||
@@ -10,18 +10,19 @@
|
||||
/>
|
||||
</Transition>
|
||||
<aside
|
||||
class="nav-drawer fixed left-0 top-0 z-30 flex h-full w-[260px] flex-col border-r border-kestrel-border bg-kestrel-surface transition-transform duration-200 ease-out"
|
||||
:class="{ '-translate-x-full': !modelValue }"
|
||||
class="nav-drawer flex h-full flex-col bg-kestrel-surface transition-[width] duration-200 ease-out md:relative md:translate-x-0"
|
||||
:class="[
|
||||
isMobile && !modelValue ? 'fixed left-0 top-14 z-30 -translate-x-full' : 'fixed left-0 top-14 z-30 md:relative md:top-0',
|
||||
showCollapsed ? 'w-16' : 'w-[260px]',
|
||||
]"
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
:aria-expanded="modelValue"
|
||||
>
|
||||
<div
|
||||
class="flex h-14 shrink-0 items-center justify-between border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm shadow-glow-header"
|
||||
v-if="isMounted && isMobile"
|
||||
class="flex shrink-0 items-center justify-end border-b border-kestrel-border bg-kestrel-surface px-2 py-1"
|
||||
>
|
||||
<h2 class="text-sm font-medium uppercase tracking-wider text-kestrel-muted">
|
||||
Navigation
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-close-btn"
|
||||
@@ -31,7 +32,7 @@
|
||||
<span class="text-xl leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 overflow-auto py-2">
|
||||
<nav class="flex-1 overflow-auto bg-kestrel-surface py-2">
|
||||
<ul class="space-y-0.5 px-2">
|
||||
<li
|
||||
v-for="item in navItems"
|
||||
@@ -39,50 +40,91 @@
|
||||
>
|
||||
<NuxtLink
|
||||
:to="item.to"
|
||||
class="block rounded px-3 py-2 text-sm transition-colors"
|
||||
:class="isActive(item.to)
|
||||
? 'border-l-2 border-kestrel-accent bg-kestrel-surface-hover font-medium text-kestrel-accent text-shadow-glow-sm'
|
||||
: 'border-l-2 border-transparent text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-text'"
|
||||
@click="close"
|
||||
class="flex items-center gap-3 rounded px-3 py-2 text-sm transition-colors"
|
||||
:class="[
|
||||
showCollapsed ? 'justify-center px-2' : '',
|
||||
isActive(item.to)
|
||||
? 'bg-kestrel-surface-hover font-medium text-kestrel-accent text-shadow-glow-sm'
|
||||
: 'text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-text',
|
||||
!showCollapsed && (isActive(item.to) ? 'border-l-2 border-kestrel-accent' : 'border-l-2 border-transparent'),
|
||||
]"
|
||||
:title="showCollapsed ? item.label : undefined"
|
||||
@click="isMobile ? close() : undefined"
|
||||
>
|
||||
{{ item.label }}
|
||||
<Icon
|
||||
:name="item.icon"
|
||||
class="size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
v-show="!showCollapsed"
|
||||
class="truncate"
|
||||
>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div
|
||||
v-if="isMounted && !isMobile"
|
||||
class="shrink-0 border-t border-kestrel-border bg-kestrel-surface py-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-text"
|
||||
:class="showCollapsed ? 'justify-center px-2' : ''"
|
||||
:aria-label="showCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
||||
@click="toggleCollapsed"
|
||||
>
|
||||
<Icon
|
||||
:name="showCollapsed ? 'tabler:chevron-right' : 'tabler:chevron-left'"
|
||||
class="size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-show="!showCollapsed">Collapse sidebar</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
collapsed: { type: Boolean, default: false },
|
||||
isMobile: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const emit = defineEmits(['update:modelValue', 'update:collapsed'])
|
||||
|
||||
const isMounted = ref(false)
|
||||
const route = useRoute()
|
||||
const { canEditPois } = useUser()
|
||||
|
||||
const NAV_ITEMS = Object.freeze([
|
||||
{ to: '/', label: 'Map', icon: 'tabler:map' },
|
||||
{ to: '/cameras', label: 'Cameras', icon: 'tabler:video' },
|
||||
{ to: '/poi', label: 'POI', icon: 'tabler:map-pin' },
|
||||
{ to: '/members', label: 'Members', icon: 'tabler:users' },
|
||||
{ to: '/account', label: 'Account', icon: 'tabler:user-circle' },
|
||||
{ to: '/settings', label: 'Settings', icon: 'tabler:settings' },
|
||||
])
|
||||
|
||||
const SHARE_LIVE_ITEM = { to: '/share-live', label: 'Share live', icon: 'tabler:live-photo' }
|
||||
|
||||
const navItems = computed(() => {
|
||||
const items = [
|
||||
{ to: '/', label: 'Map' },
|
||||
{ to: '/account', label: 'Account' },
|
||||
{ to: '/cameras', label: 'Cameras' },
|
||||
{ to: '/poi', label: 'POI' },
|
||||
{ to: '/members', label: 'Members' },
|
||||
{ to: '/settings', label: 'Settings' },
|
||||
]
|
||||
if (canEditPois.value) {
|
||||
items.splice(1, 0, { to: '/share-live', label: 'Share live' })
|
||||
}
|
||||
return items
|
||||
if (!canEditPois.value) return NAV_ITEMS
|
||||
const list = [...NAV_ITEMS]
|
||||
list.splice(3, 0, SHARE_LIVE_ITEM)
|
||||
return list
|
||||
})
|
||||
|
||||
const isActive = to => to === '/' ? route.path === '/' : route.path.startsWith(to)
|
||||
const showCollapsed = computed(() => props.collapsed && !props.isMobile)
|
||||
|
||||
function toggleCollapsed() {
|
||||
emit('update:collapsed', !props.collapsed)
|
||||
}
|
||||
|
||||
const isActive = to => (to === '/' ? route.path === '/' : route.path.startsWith(to))
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
@@ -95,6 +137,7 @@ function onEscape(e) {
|
||||
defineExpose({ close })
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true
|
||||
document.addEventListener('keydown', onEscape)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,159 +1,144 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="mode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
|
||||
@keydown.escape="$emit('close')"
|
||||
<BaseModal
|
||||
:show="show"
|
||||
:aria-labelledby="mode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div
|
||||
v-if="mode === 'add' || mode === 'edit'"
|
||||
ref="modalRef"
|
||||
class="kestrel-card-modal relative w-full max-w-md p-6"
|
||||
>
|
||||
<h2
|
||||
id="poi-modal-title"
|
||||
class="kestrel-section-heading mb-4"
|
||||
>
|
||||
{{ mode === 'edit' ? 'Edit POI' : 'Add POI' }}
|
||||
</h2>
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="$emit('submit', { label: localForm.label, iconType: localForm.iconType })"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="add-poi-label"
|
||||
class="kestrel-label"
|
||||
>Label (optional)</label>
|
||||
<input
|
||||
id="add-poi-label"
|
||||
v-model="localForm.label"
|
||||
type="text"
|
||||
placeholder="e.g. Rally point"
|
||||
class="kestrel-input"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
ref="iconRef"
|
||||
class="relative inline-block w-full"
|
||||
>
|
||||
<label class="kestrel-label">Icon type</label>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full min-w-0 items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-left text-sm text-kestrel-text transition-colors hover:border-kestrel-accent/50"
|
||||
:aria-expanded="iconOpen"
|
||||
aria-haspopup="listbox"
|
||||
:aria-label="`Icon type: ${localForm.iconType}`"
|
||||
@click="iconOpen = !iconOpen"
|
||||
>
|
||||
<span class="flex items-center gap-2 capitalize">
|
||||
<Icon
|
||||
:name="POI_ICONIFY_IDS[localForm.iconType]"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
{{ localForm.iconType }}
|
||||
</span>
|
||||
<span
|
||||
class="text-kestrel-muted transition-transform"
|
||||
:class="iconOpen && 'rotate-180'"
|
||||
>▾</span>
|
||||
</button>
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="iconOpen"
|
||||
class="absolute left-0 right-0 top-full z-10 mt-1 rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-dropdown"
|
||||
role="listbox"
|
||||
>
|
||||
<button
|
||||
v-for="opt in POI_ICON_TYPES"
|
||||
:key="opt"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="localForm.iconType === opt"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm capitalize transition-colors"
|
||||
:class="localForm.iconType === opt ? 'bg-kestrel-accent-dim text-kestrel-accent' : 'text-kestrel-text hover:bg-kestrel-border'"
|
||||
@click="localForm.iconType = opt; iconOpen = false"
|
||||
>
|
||||
<Icon
|
||||
:name="POI_ICONIFY_IDS[opt]"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ mode === 'edit' ? 'Save changes' : 'Add POI' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="mode === 'delete'"
|
||||
ref="modalRef"
|
||||
class="kestrel-card-modal relative w-full max-w-sm p-6"
|
||||
>
|
||||
<h2
|
||||
id="delete-poi-title"
|
||||
class="kestrel-section-heading mb-2"
|
||||
>
|
||||
Delete POI?
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
{{ deletePoi?.label ? `"${deletePoi.label}" will be removed.` : 'This POI will be removed.' }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/60 transition-opacity"
|
||||
aria-label="Close"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
<div
|
||||
v-if="mode === 'add' || mode === 'edit'"
|
||||
ref="modalRef"
|
||||
class="kestrel-card-modal relative w-full max-w-md p-6"
|
||||
@click.stop
|
||||
>
|
||||
<h2
|
||||
id="poi-modal-title"
|
||||
class="kestrel-section-heading mb-4"
|
||||
>
|
||||
{{ mode === 'edit' ? 'Edit POI' : 'Add POI' }}
|
||||
</h2>
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="$emit('submit', { label: localForm.label, iconType: localForm.iconType })"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="add-poi-label"
|
||||
class="kestrel-label"
|
||||
>Label (optional)</label>
|
||||
<input
|
||||
id="add-poi-label"
|
||||
v-model="localForm.label"
|
||||
type="text"
|
||||
placeholder="e.g. Rally point"
|
||||
class="kestrel-input"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
ref="iconRef"
|
||||
class="relative inline-block w-full"
|
||||
>
|
||||
<label class="kestrel-label">Icon type</label>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full min-w-0 items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-3 py-2 text-left text-sm text-kestrel-text transition-colors hover:border-kestrel-accent/50"
|
||||
:aria-expanded="iconOpen"
|
||||
aria-haspopup="listbox"
|
||||
:aria-label="`Icon type: ${localForm.iconType}`"
|
||||
@click="iconOpen = !iconOpen"
|
||||
>
|
||||
<span class="flex items-center gap-2 capitalize">
|
||||
<Icon
|
||||
:name="POI_ICONIFY_IDS[localForm.iconType]"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
{{ localForm.iconType }}
|
||||
</span>
|
||||
<span
|
||||
class="text-kestrel-muted transition-transform"
|
||||
:class="iconOpen && 'rotate-180'"
|
||||
>▾</span>
|
||||
</button>
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="iconOpen"
|
||||
class="absolute left-0 right-0 top-full z-10 mt-1 rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-dropdown"
|
||||
role="listbox"
|
||||
>
|
||||
<button
|
||||
v-for="opt in POI_ICON_TYPES"
|
||||
:key="opt"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="localForm.iconType === opt"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm capitalize transition-colors"
|
||||
:class="localForm.iconType === opt ? 'bg-kestrel-accent-dim text-kestrel-accent' : 'text-kestrel-text hover:bg-kestrel-border'"
|
||||
@click="localForm.iconType = opt; iconOpen = false"
|
||||
>
|
||||
<Icon
|
||||
:name="POI_ICONIFY_IDS[opt]"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-kestrel-accent px-4 py-2 text-sm font-medium text-kestrel-bg transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ mode === 'edit' ? 'Save changes' : 'Add POI' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
v-if="mode === 'delete'"
|
||||
ref="modalRef"
|
||||
class="kestrel-card-modal relative w-full max-w-sm p-6"
|
||||
@click.stop
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
@click="$emit('confirmDelete')"
|
||||
>
|
||||
<h2
|
||||
id="delete-poi-title"
|
||||
class="kestrel-section-heading mb-2"
|
||||
>
|
||||
Delete POI?
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
{{ deletePoi?.label ? `"${deletePoi.label}" will be removed.` : 'This POI will be removed.' }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
@click="$emit('confirmDelete')"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
84
app/components/UserMenu.vue
Normal file
84
app/components/UserMenu.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<AppDropdown
|
||||
:open="open"
|
||||
@close="open = false"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex rounded-full border border-kestrel-border bg-kestrel-surface p-0.5 transition-colors hover:bg-kestrel-border hover:border-kestrel-accent"
|
||||
aria-label="User menu"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="true"
|
||||
@click="open = !open"
|
||||
>
|
||||
<img
|
||||
v-if="user?.avatar_url"
|
||||
:src="user.avatar_url"
|
||||
:alt="user.identifier"
|
||||
class="h-8 w-8 rounded-full object-cover"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-kestrel-border text-xs font-medium text-kestrel-text"
|
||||
>
|
||||
{{ initials }}
|
||||
</span>
|
||||
</button>
|
||||
<template #menu>
|
||||
<NuxtLink
|
||||
to="/account"
|
||||
class="kestrel-context-menu-item"
|
||||
role="menuitem"
|
||||
@click="open = false"
|
||||
>
|
||||
Profile
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/settings"
|
||||
class="kestrel-context-menu-item"
|
||||
role="menuitem"
|
||||
@click="open = false"
|
||||
>
|
||||
Settings
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-context-menu-item-danger w-full"
|
||||
role="menuitem"
|
||||
@click="onSignOut"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</template>
|
||||
</AppDropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['signout'])
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
const initials = computed(() => {
|
||||
const id = props.user?.identifier ?? ''
|
||||
const parts = id.trim().split(/\s+/)
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
|
||||
return id.slice(0, 2).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
function onSignOut() {
|
||||
open.value = false
|
||||
emit('signout')
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
watch(() => route.path, () => {
|
||||
open.value = false
|
||||
})
|
||||
</script>
|
||||
21
app/composables/useMediaQuery.js
Normal file
21
app/composables/useMediaQuery.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Reactive viewport media query. SSR-safe: defaults to true (mobile) so sidebar closed on first paint.
|
||||
* @param {string} query - CSS media query, e.g. '(max-width: 767px)'
|
||||
* @returns {import('vue').Ref<boolean>} Ref that is true when the media query matches.
|
||||
*/
|
||||
export function useMediaQuery(query) {
|
||||
const matches = ref(true)
|
||||
let mql = null
|
||||
const handler = (e) => {
|
||||
matches.value = e.matches
|
||||
}
|
||||
onMounted(() => {
|
||||
mql = window.matchMedia(query)
|
||||
matches.value = mql.matches
|
||||
mql.addEventListener('change', handler)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (mql) mql.removeEventListener('change', handler)
|
||||
})
|
||||
return matches
|
||||
}
|
||||
@@ -1,71 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-kestrel-bg text-kestrel-text font-mono flex flex-col">
|
||||
<div class="relative flex flex-1 min-h-0">
|
||||
<NavDrawer v-model="drawerOpen" />
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col transition-[margin] duration-200 ease-out"
|
||||
:class="{ 'md:ml-[260px]': drawerOpen }"
|
||||
>
|
||||
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-kestrel-border bg-kestrel-surface px-4 shadow-glow-sm shadow-glow-header">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-2 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
aria-label="Toggle navigation"
|
||||
:aria-expanded="drawerOpen"
|
||||
@click="drawerOpen = !drawerOpen"
|
||||
>
|
||||
<span
|
||||
class="text-lg leading-none"
|
||||
aria-hidden="true"
|
||||
>☰</span>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-lg font-semibold tracking-wide text-kestrel-text text-shadow-glow-md">
|
||||
KestrelOS
|
||||
</h1>
|
||||
<p class="text-xs uppercase tracking-widest text-kestrel-muted">
|
||||
> Tactical Operations Center — OSINT Feeds
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="user">
|
||||
<span class="text-xs text-kestrel-muted">{{ user.identifier }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
@click="onLogout"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</template>
|
||||
<NuxtLink
|
||||
v-else
|
||||
to="/login"
|
||||
class="rounded px-2 py-1 text-xs text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-accent"
|
||||
>
|
||||
Sign in
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</header>
|
||||
<main class="min-h-0 flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-screen flex-col overflow-hidden bg-kestrel-bg font-mono text-kestrel-text">
|
||||
<AppShell>
|
||||
<slot />
|
||||
</AppShell>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const drawerOpen = ref(true)
|
||||
const { user, refresh } = useUser()
|
||||
const route = useRoute()
|
||||
|
||||
async function onLogout() {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
await refresh()
|
||||
await navigateTo('/')
|
||||
}
|
||||
watch(() => route.path, () => {
|
||||
drawerOpen.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,51 @@
|
||||
Account
|
||||
</h2>
|
||||
|
||||
<section
|
||||
v-if="user"
|
||||
class="mb-8"
|
||||
>
|
||||
<h3 class="kestrel-section-label">
|
||||
Profile photo
|
||||
</h3>
|
||||
<div class="kestrel-card flex items-center gap-4 p-4">
|
||||
<div class="flex h-16 w-16 shrink-0 overflow-hidden rounded-full border border-kestrel-border bg-kestrel-border">
|
||||
<img
|
||||
v-if="user.avatar_url"
|
||||
:src="`${user.avatar_url}${avatarBust ? `?t=${avatarBust}` : ''}`"
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center text-lg font-medium text-kestrel-text"
|
||||
>
|
||||
{{ accountInitials }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label class="kestrel-btn-secondary cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png"
|
||||
class="sr-only"
|
||||
:disabled="avatarLoading"
|
||||
@change="onAvatarFileChange"
|
||||
>
|
||||
{{ avatarLoading ? 'Uploading…' : 'Upload' }}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary disabled:opacity-50"
|
||||
:disabled="avatarLoading || !user.avatar_url"
|
||||
@click="onRemoveAvatar"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-8">
|
||||
<h3 class="kestrel-section-label">
|
||||
Profile
|
||||
@@ -126,8 +171,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { user } = useUser()
|
||||
const { user, refresh } = useUser()
|
||||
|
||||
const avatarBust = ref(0)
|
||||
const avatarLoading = ref(false)
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
@@ -135,6 +182,45 @@ const passwordLoading = ref(false)
|
||||
const passwordSuccess = ref(false)
|
||||
const passwordError = ref('')
|
||||
|
||||
const accountInitials = computed(() => {
|
||||
const id = user.value?.identifier ?? ''
|
||||
const parts = id.trim().split(/\s+/)
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
|
||||
return id.slice(0, 2).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
async function onAvatarFileChange(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
avatarLoading.value = true
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('avatar', file, file.name)
|
||||
await $fetch('/api/me/avatar', { method: 'PUT', body: form, credentials: 'include' })
|
||||
avatarBust.value = Date.now()
|
||||
await refresh()
|
||||
}
|
||||
catch {
|
||||
// Error surfaced by refresh or network
|
||||
}
|
||||
finally {
|
||||
avatarLoading.value = false
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoveAvatar() {
|
||||
avatarLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/me/avatar', { method: 'DELETE', credentials: 'include' })
|
||||
avatarBust.value = Date.now()
|
||||
await refresh()
|
||||
}
|
||||
finally {
|
||||
avatarLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onChangePassword() {
|
||||
passwordError.value = ''
|
||||
passwordSuccess.value = false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-5rem)] w-full flex-col md:flex-row">
|
||||
<div class="relative h-2/3 w-full md:h-full md:flex-1">
|
||||
<div class="flex h-full w-full flex-col md:flex-row">
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<ClientOnly>
|
||||
<KestrelMap
|
||||
:devices="devices ?? []"
|
||||
|
||||
@@ -34,357 +34,38 @@
|
||||
Add user
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded border border-kestrel-border">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Identifier
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Auth
|
||||
</th>
|
||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-2 font-medium text-kestrel-text"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
class="border-b border-kestrel-border"
|
||||
>
|
||||
<td class="px-4 py-2 text-kestrel-text">
|
||||
{{ u.identifier }}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<span
|
||||
class="rounded px-1.5 py-0.5 text-xs text-kestrel-muted"
|
||||
:class="u.auth_provider === 'oidc' ? 'bg-kestrel-surface' : ''"
|
||||
>
|
||||
{{ u.auth_provider === 'oidc' ? 'OIDC' : 'Local' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
:ref="el => setDropdownWrapRef(u.id, el)"
|
||||
class="relative inline-block"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-[6rem] items-center justify-between gap-2 rounded border border-kestrel-border bg-kestrel-bg px-2 py-1 text-left text-sm text-kestrel-text shadow-sm transition-colors hover:border-kestrel-accent/50 hover:bg-kestrel-surface"
|
||||
:aria-expanded="openRoleDropdownId === u.id"
|
||||
:aria-haspopup="true"
|
||||
aria-label="Change role"
|
||||
@click.stop="toggleRoleDropdown(u.id)"
|
||||
>
|
||||
<span>{{ roleByUserId[u.id] ?? u.role }}</span>
|
||||
<span
|
||||
class="text-kestrel-muted transition-transform"
|
||||
:class="openRoleDropdownId === u.id && 'rotate-180'"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="text-kestrel-muted"
|
||||
>{{ u.role }}</span>
|
||||
</td>
|
||||
<td
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-if="roleByUserId[u.id] !== u.role"
|
||||
type="button"
|
||||
class="rounded border border-kestrel-accent px-2 py-1 text-xs text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
@click="saveRole(u.id)"
|
||||
>
|
||||
Save role
|
||||
</button>
|
||||
<template v-if="u.auth_provider !== 'oidc'">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-kestrel-border px-2 py-1 text-xs text-kestrel-text hover:bg-kestrel-surface"
|
||||
@click="openEditUser(u)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
v-if="u.id !== user?.id"
|
||||
type="button"
|
||||
class="rounded border border-red-500/60 px-2 py-1 text-xs text-red-400 hover:bg-red-500/10"
|
||||
@click="openDeleteConfirm(u)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="openRoleDropdownId && dropdownPlacement"
|
||||
ref="dropdownMenuRef"
|
||||
role="menu"
|
||||
class="fixed z-[100] min-w-[6rem] rounded border border-kestrel-border bg-kestrel-surface py-1 shadow-glow shadow-glow-dropdown"
|
||||
:style="{
|
||||
top: `${dropdownPlacement.top}px`,
|
||||
left: `${dropdownPlacement.left}px`,
|
||||
minWidth: `${dropdownPlacement.minWidth}px`,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-for="role in roleOptions"
|
||||
:key="role"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm transition-colors"
|
||||
:class="roleByUserId[openRoleDropdownId] === role
|
||||
? 'bg-kestrel-accent-dim text-kestrel-accent'
|
||||
: 'text-kestrel-text hover:bg-kestrel-border hover:text-kestrel-text'"
|
||||
@click.stop="selectRole(openRoleDropdownId, role)"
|
||||
>
|
||||
{{ role }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<MembersTable
|
||||
:users="users"
|
||||
:role-by-user-id="roleByUserId"
|
||||
:role-options="roleOptions"
|
||||
:is-admin="isAdmin"
|
||||
:current-user-id="user?.id ?? null"
|
||||
:open-role-dropdown-id="openRoleDropdownId"
|
||||
@toggle-role-dropdown="toggleRoleDropdown"
|
||||
@close-role-dropdown="openRoleDropdownId = null"
|
||||
@select-role="selectRole"
|
||||
@save-role="saveRole"
|
||||
@edit-user="openEditUser"
|
||||
@delete-confirm="openDeleteConfirm"
|
||||
/>
|
||||
<!-- Add user modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="addUserModalOpen"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-user-title"
|
||||
@click.self="closeAddUserModal"
|
||||
>
|
||||
<div
|
||||
class="kestrel-card-modal w-full max-w-sm p-4"
|
||||
@click.stop
|
||||
>
|
||||
<h3
|
||||
id="add-user-title"
|
||||
class="mb-3 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Add user
|
||||
</h3>
|
||||
<form @submit.prevent="submitAddUser">
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-identifier"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Username</label>
|
||||
<input
|
||||
id="add-identifier"
|
||||
v-model="newUser.identifier"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="kestrel-input"
|
||||
placeholder="username"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-password"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Password</label>
|
||||
<input
|
||||
id="add-password"
|
||||
v-model="newUser.password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="kestrel-input"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<label
|
||||
for="add-role"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Role</label>
|
||||
<select
|
||||
id="add-role"
|
||||
v-model="newUser.role"
|
||||
class="kestrel-input"
|
||||
>
|
||||
<option value="member">
|
||||
member
|
||||
</option>
|
||||
<option value="leader">
|
||||
leader
|
||||
</option>
|
||||
<option value="admin">
|
||||
admin
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
v-if="createError"
|
||||
class="mb-2 text-xs text-red-400"
|
||||
>
|
||||
{{ createError }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="closeAddUserModal"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
>
|
||||
Add user
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<!-- Delete user confirmation modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="deleteConfirmUser"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-user-title"
|
||||
@click.self="deleteConfirmUser = null"
|
||||
>
|
||||
<div
|
||||
class="kestrel-card-modal w-full max-w-sm p-4"
|
||||
@click.stop
|
||||
>
|
||||
<h3
|
||||
id="delete-user-title"
|
||||
class="mb-2 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Delete user?
|
||||
</h3>
|
||||
<p class="mb-4 text-sm text-kestrel-muted">
|
||||
Are you sure you want to delete <strong class="text-kestrel-text">{{ deleteConfirmUser?.identifier }}</strong>? They will not be able to sign in again.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="deleteConfirmUser = null"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-red-500/60 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/20"
|
||||
@click="confirmDeleteUser"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="editUserModal"
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
||||
@click.self="editUserModal = null"
|
||||
>
|
||||
<div
|
||||
class="kestrel-card-modal w-full max-w-sm p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-user-title"
|
||||
>
|
||||
<h3
|
||||
id="edit-user-title"
|
||||
class="mb-3 text-sm font-medium text-kestrel-text"
|
||||
>
|
||||
Edit local user
|
||||
</h3>
|
||||
<form @submit.prevent="submitEditUser">
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<label
|
||||
for="edit-identifier"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>Identifier</label>
|
||||
<input
|
||||
id="edit-identifier"
|
||||
v-model="editForm.identifier"
|
||||
type="text"
|
||||
required
|
||||
class="kestrel-input"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
<label
|
||||
for="edit-password"
|
||||
class="text-xs text-kestrel-muted"
|
||||
>New password (leave blank to keep)</label>
|
||||
<input
|
||||
id="edit-password"
|
||||
v-model="editForm.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="kestrel-input"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
<p class="mt-0.5 text-xs text-kestrel-muted">
|
||||
If you change your password, use the new one next time you sign in.
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="editError"
|
||||
class="mb-2 text-xs text-red-400"
|
||||
>
|
||||
{{ editError }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="kestrel-btn-secondary"
|
||||
@click="editUserModal = null"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded border border-kestrel-accent px-3 py-1.5 text-sm text-kestrel-accent hover:bg-kestrel-accent-dim"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<AddUserModal
|
||||
:show="addUserModalOpen"
|
||||
:submit-error="createError"
|
||||
@close="closeAddUserModal"
|
||||
@submit="onAddUserSubmit"
|
||||
/>
|
||||
<DeleteUserConfirmModal
|
||||
:user="deleteConfirmUser"
|
||||
@close="deleteConfirmUser = null"
|
||||
@confirm="confirmDeleteUser"
|
||||
/>
|
||||
<EditUserModal
|
||||
:user="editUserModal"
|
||||
:submit-error="editError"
|
||||
@close="editUserModal = null"
|
||||
@submit="onEditUserSubmit"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -406,80 +87,26 @@ const roleByUserId = computed(() => {
|
||||
return { ...base, ...pendingRoleUpdates.value }
|
||||
})
|
||||
const openRoleDropdownId = ref(null)
|
||||
const dropdownWrapRefs = ref({})
|
||||
const dropdownPlacement = ref(null)
|
||||
const dropdownMenuRef = ref(null)
|
||||
|
||||
const addUserModalOpen = ref(false)
|
||||
const newUser = ref({ identifier: '', password: '', role: 'member' })
|
||||
const createError = ref('')
|
||||
const editUserModal = ref(null)
|
||||
const editForm = ref({ identifier: '', password: '' })
|
||||
const editError = ref('')
|
||||
const deleteConfirmUser = ref(null)
|
||||
|
||||
function setDropdownWrapRef(userId, el) {
|
||||
if (el) dropdownWrapRefs.value[userId] = el
|
||||
else {
|
||||
dropdownWrapRefs.value = Object.fromEntries(
|
||||
Object.entries(dropdownWrapRefs.value).filter(([k]) => k !== userId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
watch(user, () => {
|
||||
if (canEditPois.value) refreshUsers()
|
||||
}, { immediate: true })
|
||||
|
||||
function toggleRoleDropdown(userId) {
|
||||
if (openRoleDropdownId.value === userId) {
|
||||
openRoleDropdownId.value = null
|
||||
dropdownPlacement.value = null
|
||||
return
|
||||
}
|
||||
openRoleDropdownId.value = userId
|
||||
nextTick(() => {
|
||||
const wrap = dropdownWrapRefs.value[userId]
|
||||
if (wrap) {
|
||||
const rect = wrap.getBoundingClientRect()
|
||||
dropdownPlacement.value = {
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
minWidth: Math.max(rect.width, 96),
|
||||
}
|
||||
}
|
||||
else {
|
||||
dropdownPlacement.value = { top: 0, left: 0, minWidth: 96 }
|
||||
}
|
||||
})
|
||||
openRoleDropdownId.value = openRoleDropdownId.value === userId ? null : userId
|
||||
}
|
||||
|
||||
function selectRole(userId, role) {
|
||||
pendingRoleUpdates.value = { ...pendingRoleUpdates.value, [userId]: role }
|
||||
openRoleDropdownId.value = null
|
||||
dropdownPlacement.value = null
|
||||
}
|
||||
|
||||
function onDocumentClick(e) {
|
||||
const openId = openRoleDropdownId.value
|
||||
if (openId == null) return
|
||||
const wrap = dropdownWrapRefs.value[openId]
|
||||
const menu = dropdownMenuRef.value
|
||||
const inTrigger = wrap && wrap.contains(e.target)
|
||||
const inMenu = menu && menu.contains(e.target)
|
||||
if (!inTrigger && !inMenu) {
|
||||
openRoleDropdownId.value = null
|
||||
dropdownPlacement.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
async function saveRole(id) {
|
||||
const role = roleByUserId.value[id]
|
||||
if (!role) return
|
||||
@@ -497,7 +124,6 @@ async function saveRole(id) {
|
||||
|
||||
function openAddUserModal() {
|
||||
addUserModalOpen.value = true
|
||||
newUser.value = { identifier: '', password: '', role: 'member' }
|
||||
createError.value = ''
|
||||
}
|
||||
|
||||
@@ -506,15 +132,15 @@ function closeAddUserModal() {
|
||||
createError.value = ''
|
||||
}
|
||||
|
||||
async function submitAddUser() {
|
||||
async function onAddUserSubmit(payload) {
|
||||
createError.value = ''
|
||||
try {
|
||||
await $fetch('/api/users', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
identifier: newUser.value.identifier.trim(),
|
||||
password: newUser.value.password,
|
||||
role: newUser.value.role,
|
||||
identifier: payload.identifier,
|
||||
password: payload.password,
|
||||
role: payload.role,
|
||||
},
|
||||
})
|
||||
closeAddUserModal()
|
||||
@@ -527,21 +153,19 @@ async function submitAddUser() {
|
||||
|
||||
function openEditUser(u) {
|
||||
editUserModal.value = u
|
||||
editForm.value = { identifier: u.identifier, password: '' }
|
||||
editError.value = ''
|
||||
}
|
||||
|
||||
async function submitEditUser() {
|
||||
if (!editUserModal.value) return
|
||||
async function onEditUserSubmit(payload) {
|
||||
const u = editUserModal.value
|
||||
if (!u) return
|
||||
editError.value = ''
|
||||
const id = editUserModal.value.id
|
||||
const body = { identifier: editForm.value.identifier.trim() }
|
||||
if (editForm.value.password) body.password = editForm.value.password
|
||||
const body = { identifier: payload.identifier.trim() }
|
||||
if (payload.password) body.password = payload.password
|
||||
try {
|
||||
await $fetch(`/api/users/${id}`, { method: 'PATCH', body })
|
||||
await $fetch(`/api/users/${u.id}`, { method: 'PATCH', body })
|
||||
editUserModal.value = null
|
||||
await refreshUsers()
|
||||
// If you edited yourself, refresh current user so the header/nav shows the new identifier
|
||||
await refreshUser()
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user