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>
|
<template>
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage :key="$route.path" />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
.drawer-backdrop-enter-active, .drawer-backdrop-leave-active { transition: opacity 0.2s ease; }
|
.drawer-backdrop-enter-active, .drawer-backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||||
.modal-enter-from, .modal-leave-to,
|
.modal-enter-from, .modal-leave-to,
|
||||||
.drawer-backdrop-enter-from, .drawer-backdrop-leave-to { opacity: 0; }
|
.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-active .relative, .modal-leave-active .relative { transition: transform 0.2s ease; }
|
||||||
.modal-enter-from .relative, .modal-leave-to .relative { transform: scale(0.96); }
|
.modal-enter-from .relative, .modal-leave-to .relative { transform: scale(0.96); }
|
||||||
|
|
||||||
@@ -36,6 +38,10 @@
|
|||||||
.kestrel-map-container {
|
.kestrel-map-container {
|
||||||
background: #000 !important;
|
background: #000 !important;
|
||||||
}
|
}
|
||||||
|
.kestrel-map-container .leaflet-container {
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
.kestrel-map-container .leaflet-tile-pane,
|
.kestrel-map-container .leaflet-tile-pane,
|
||||||
.kestrel-map-container .leaflet-map-pane,
|
.kestrel-map-container .leaflet-map-pane,
|
||||||
.kestrel-map-container .leaflet-tile-container {
|
.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()
|
updateMarkers()
|
||||||
updatePoiMarkers()
|
updatePoiMarkers()
|
||||||
updateLiveMarkers()
|
updateLiveMarkers()
|
||||||
|
nextTick(() => map.invalidateSize())
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMarkers() {
|
function updateMarkers() {
|
||||||
@@ -403,6 +404,8 @@ function initMapWithLocation() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resizeObserver = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!import.meta.client || typeof document === 'undefined') return
|
if (!import.meta.client || typeof document === 'undefined') return
|
||||||
const [leaflet, offline] = await Promise.all([
|
const [leaflet, offline] = await Promise.all([
|
||||||
@@ -422,6 +425,15 @@ onMounted(async () => {
|
|||||||
leafletRef.value = { L, offlineApi: offline }
|
leafletRef.value = { L, offlineApi: offline }
|
||||||
initMapWithLocation()
|
initMapWithLocation()
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick)
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
if (mapRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
mapContext.value?.map?.invalidateSize()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(mapRef.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function onDocumentClick(e) {
|
function onDocumentClick(e) {
|
||||||
@@ -430,6 +442,10 @@ function onDocumentClick(e) {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', onDocumentClick)
|
document.removeEventListener('click', onDocumentClick)
|
||||||
|
if (resizeObserver && mapRef.value) {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
}
|
||||||
destroyMap()
|
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>
|
<template>
|
||||||
<Teleport to="body">
|
<div class="flex h-full shrink-0">
|
||||||
<Transition name="drawer-backdrop">
|
<Transition name="drawer-backdrop">
|
||||||
<button
|
<button
|
||||||
v-if="modelValue"
|
v-if="isMobile && modelValue"
|
||||||
type="button"
|
type="button"
|
||||||
class="fixed inset-0 z-20 block h-full w-full border-0 bg-black/50 p-0 md:hidden"
|
class="fixed inset-0 z-20 block h-full w-full border-0 bg-black/50 p-0 md:hidden"
|
||||||
aria-label="Close navigation"
|
aria-label="Close navigation"
|
||||||
@@ -10,18 +10,19 @@
|
|||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
<aside
|
<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="nav-drawer flex h-full flex-col bg-kestrel-surface transition-[width] duration-200 ease-out md:relative md:translate-x-0"
|
||||||
:class="{ '-translate-x-full': !modelValue }"
|
: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"
|
role="navigation"
|
||||||
aria-label="Main navigation"
|
aria-label="Main navigation"
|
||||||
:aria-expanded="modelValue"
|
:aria-expanded="modelValue"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="kestrel-close-btn"
|
class="kestrel-close-btn"
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
<span class="text-xl leading-none">×</span>
|
<span class="text-xl leading-none">×</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<ul class="space-y-0.5 px-2">
|
||||||
<li
|
<li
|
||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
@@ -39,50 +40,91 @@
|
|||||||
>
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
class="block rounded px-3 py-2 text-sm transition-colors"
|
class="flex items-center gap-3 rounded px-3 py-2 text-sm transition-colors"
|
||||||
:class="isActive(item.to)
|
:class="[
|
||||||
? 'border-l-2 border-kestrel-accent bg-kestrel-surface-hover font-medium text-kestrel-accent text-shadow-glow-sm'
|
showCollapsed ? 'justify-center px-2' : '',
|
||||||
: 'border-l-2 border-transparent text-kestrel-muted hover:bg-kestrel-border hover:text-kestrel-text'"
|
isActive(item.to)
|
||||||
@click="close"
|
? '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>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</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>
|
</aside>
|
||||||
</Teleport>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: { type: Boolean, default: false },
|
||||||
type: Boolean,
|
collapsed: { type: Boolean, default: false },
|
||||||
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 route = useRoute()
|
||||||
const { canEditPois } = useUser()
|
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 navItems = computed(() => {
|
||||||
const items = [
|
if (!canEditPois.value) return NAV_ITEMS
|
||||||
{ to: '/', label: 'Map' },
|
const list = [...NAV_ITEMS]
|
||||||
{ to: '/account', label: 'Account' },
|
list.splice(3, 0, SHARE_LIVE_ITEM)
|
||||||
{ to: '/cameras', label: 'Cameras' },
|
return list
|
||||||
{ 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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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() {
|
function close() {
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
@@ -95,6 +137,7 @@ function onEscape(e) {
|
|||||||
defineExpose({ close })
|
defineExpose({ close })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
isMounted.value = true
|
||||||
document.addEventListener('keydown', onEscape)
|
document.addEventListener('keydown', onEscape)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,159 +1,144 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<BaseModal
|
||||||
<Transition name="modal">
|
:show="show"
|
||||||
<div
|
:aria-labelledby="mode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
|
||||||
v-if="show"
|
@close="$emit('close')"
|
||||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4"
|
>
|
||||||
role="dialog"
|
<div
|
||||||
aria-modal="true"
|
v-if="mode === 'add' || mode === 'edit'"
|
||||||
:aria-labelledby="mode === 'delete' ? 'delete-poi-title' : 'poi-modal-title'"
|
ref="modalRef"
|
||||||
@keydown.escape="$emit('close')"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute inset-0 bg-black/60 transition-opacity"
|
class="kestrel-btn-secondary"
|
||||||
aria-label="Close"
|
|
||||||
@click="$emit('close')"
|
@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
|
Cancel
|
||||||
id="poi-modal-title"
|
</button>
|
||||||
class="kestrel-section-heading mb-4"
|
<button
|
||||||
>
|
type="button"
|
||||||
{{ mode === 'edit' ? 'Edit POI' : 'Add POI' }}
|
class="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||||
</h2>
|
@click="$emit('confirmDelete')"
|
||||||
<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
|
|
||||||
>
|
>
|
||||||
<h2
|
Delete
|
||||||
id="delete-poi-title"
|
</button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</div>
|
||||||
</Teleport>
|
</BaseModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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>
|
<template>
|
||||||
<div class="min-h-screen bg-kestrel-bg text-kestrel-text font-mono flex flex-col">
|
<div class="flex h-screen flex-col overflow-hidden bg-kestrel-bg font-mono text-kestrel-text">
|
||||||
<div class="relative flex flex-1 min-h-0">
|
<AppShell>
|
||||||
<NavDrawer v-model="drawerOpen" />
|
<slot />
|
||||||
<div
|
</AppShell>
|
||||||
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>
|
</div>
|
||||||
</template>
|
</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
|
Account
|
||||||
</h2>
|
</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">
|
<section class="mb-8">
|
||||||
<h3 class="kestrel-section-label">
|
<h3 class="kestrel-section-label">
|
||||||
Profile
|
Profile
|
||||||
@@ -126,8 +171,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const { user } = useUser()
|
const { user, refresh } = useUser()
|
||||||
|
|
||||||
|
const avatarBust = ref(0)
|
||||||
|
const avatarLoading = ref(false)
|
||||||
const currentPassword = ref('')
|
const currentPassword = ref('')
|
||||||
const newPassword = ref('')
|
const newPassword = ref('')
|
||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
@@ -135,6 +182,45 @@ const passwordLoading = ref(false)
|
|||||||
const passwordSuccess = ref(false)
|
const passwordSuccess = ref(false)
|
||||||
const passwordError = ref('')
|
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() {
|
async function onChangePassword() {
|
||||||
passwordError.value = ''
|
passwordError.value = ''
|
||||||
passwordSuccess.value = false
|
passwordSuccess.value = false
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-[calc(100vh-5rem)] w-full flex-col md:flex-row">
|
<div class="flex h-full w-full flex-col md:flex-row">
|
||||||
<div class="relative h-2/3 w-full md:h-full md:flex-1">
|
<div class="relative min-h-0 flex-1">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<KestrelMap
|
<KestrelMap
|
||||||
:devices="devices ?? []"
|
:devices="devices ?? []"
|
||||||
|
|||||||
@@ -34,357 +34,38 @@
|
|||||||
Add user
|
Add user
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto rounded border border-kestrel-border">
|
<MembersTable
|
||||||
<table class="w-full text-left text-sm">
|
:users="users"
|
||||||
<thead>
|
:role-by-user-id="roleByUserId"
|
||||||
<tr class="border-b border-kestrel-border bg-kestrel-surface-hover">
|
:role-options="roleOptions"
|
||||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
:is-admin="isAdmin"
|
||||||
Identifier
|
:current-user-id="user?.id ?? null"
|
||||||
</th>
|
:open-role-dropdown-id="openRoleDropdownId"
|
||||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
@toggle-role-dropdown="toggleRoleDropdown"
|
||||||
Auth
|
@close-role-dropdown="openRoleDropdownId = null"
|
||||||
</th>
|
@select-role="selectRole"
|
||||||
<th class="px-4 py-2 font-medium text-kestrel-text">
|
@save-role="saveRole"
|
||||||
Role
|
@edit-user="openEditUser"
|
||||||
</th>
|
@delete-confirm="openDeleteConfirm"
|
||||||
<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>
|
|
||||||
<!-- Add user modal -->
|
<!-- Add user modal -->
|
||||||
<Teleport to="body">
|
<AddUserModal
|
||||||
<div
|
:show="addUserModalOpen"
|
||||||
v-if="addUserModalOpen"
|
:submit-error="createError"
|
||||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
|
@close="closeAddUserModal"
|
||||||
role="dialog"
|
@submit="onAddUserSubmit"
|
||||||
aria-modal="true"
|
/>
|
||||||
aria-labelledby="add-user-title"
|
<DeleteUserConfirmModal
|
||||||
@click.self="closeAddUserModal"
|
:user="deleteConfirmUser"
|
||||||
>
|
@close="deleteConfirmUser = null"
|
||||||
<div
|
@confirm="confirmDeleteUser"
|
||||||
class="kestrel-card-modal w-full max-w-sm p-4"
|
/>
|
||||||
@click.stop
|
<EditUserModal
|
||||||
>
|
:user="editUserModal"
|
||||||
<h3
|
:submit-error="editError"
|
||||||
id="add-user-title"
|
@close="editUserModal = null"
|
||||||
class="mb-3 text-sm font-medium text-kestrel-text"
|
@submit="onEditUserSubmit"
|
||||||
>
|
/>
|
||||||
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>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -406,80 +87,26 @@ const roleByUserId = computed(() => {
|
|||||||
return { ...base, ...pendingRoleUpdates.value }
|
return { ...base, ...pendingRoleUpdates.value }
|
||||||
})
|
})
|
||||||
const openRoleDropdownId = ref(null)
|
const openRoleDropdownId = ref(null)
|
||||||
const dropdownWrapRefs = ref({})
|
|
||||||
const dropdownPlacement = ref(null)
|
|
||||||
const dropdownMenuRef = ref(null)
|
|
||||||
|
|
||||||
const addUserModalOpen = ref(false)
|
const addUserModalOpen = ref(false)
|
||||||
const newUser = ref({ identifier: '', password: '', role: 'member' })
|
|
||||||
const createError = ref('')
|
const createError = ref('')
|
||||||
const editUserModal = ref(null)
|
const editUserModal = ref(null)
|
||||||
const editForm = ref({ identifier: '', password: '' })
|
|
||||||
const editError = ref('')
|
const editError = ref('')
|
||||||
const deleteConfirmUser = ref(null)
|
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, () => {
|
watch(user, () => {
|
||||||
if (canEditPois.value) refreshUsers()
|
if (canEditPois.value) refreshUsers()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
function toggleRoleDropdown(userId) {
|
function toggleRoleDropdown(userId) {
|
||||||
if (openRoleDropdownId.value === userId) {
|
openRoleDropdownId.value = openRoleDropdownId.value === userId ? null : 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 }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRole(userId, role) {
|
function selectRole(userId, role) {
|
||||||
pendingRoleUpdates.value = { ...pendingRoleUpdates.value, [userId]: role }
|
pendingRoleUpdates.value = { ...pendingRoleUpdates.value, [userId]: role }
|
||||||
openRoleDropdownId.value = null
|
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) {
|
async function saveRole(id) {
|
||||||
const role = roleByUserId.value[id]
|
const role = roleByUserId.value[id]
|
||||||
if (!role) return
|
if (!role) return
|
||||||
@@ -497,7 +124,6 @@ async function saveRole(id) {
|
|||||||
|
|
||||||
function openAddUserModal() {
|
function openAddUserModal() {
|
||||||
addUserModalOpen.value = true
|
addUserModalOpen.value = true
|
||||||
newUser.value = { identifier: '', password: '', role: 'member' }
|
|
||||||
createError.value = ''
|
createError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,15 +132,15 @@ function closeAddUserModal() {
|
|||||||
createError.value = ''
|
createError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAddUser() {
|
async function onAddUserSubmit(payload) {
|
||||||
createError.value = ''
|
createError.value = ''
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/users', {
|
await $fetch('/api/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
identifier: newUser.value.identifier.trim(),
|
identifier: payload.identifier,
|
||||||
password: newUser.value.password,
|
password: payload.password,
|
||||||
role: newUser.value.role,
|
role: payload.role,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
closeAddUserModal()
|
closeAddUserModal()
|
||||||
@@ -527,21 +153,19 @@ async function submitAddUser() {
|
|||||||
|
|
||||||
function openEditUser(u) {
|
function openEditUser(u) {
|
||||||
editUserModal.value = u
|
editUserModal.value = u
|
||||||
editForm.value = { identifier: u.identifier, password: '' }
|
|
||||||
editError.value = ''
|
editError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitEditUser() {
|
async function onEditUserSubmit(payload) {
|
||||||
if (!editUserModal.value) return
|
const u = editUserModal.value
|
||||||
|
if (!u) return
|
||||||
editError.value = ''
|
editError.value = ''
|
||||||
const id = editUserModal.value.id
|
const body = { identifier: payload.identifier.trim() }
|
||||||
const body = { identifier: editForm.value.identifier.trim() }
|
if (payload.password) body.password = payload.password
|
||||||
if (editForm.value.password) body.password = editForm.value.password
|
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/users/${id}`, { method: 'PATCH', body })
|
await $fetch(`/api/users/${u.id}`, { method: 'PATCH', body })
|
||||||
editUserModal.value = null
|
editUserModal.value = null
|
||||||
await refreshUsers()
|
await refreshUsers()
|
||||||
// If you edited yourself, refresh current user so the header/nav shows the new identifier
|
|
||||||
await refreshUser()
|
await refreshUser()
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const user = event.context.user
|
const user = event.context.user
|
||||||
if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
|
if (!user) throw createError({ statusCode: 401, message: 'Unauthorized' })
|
||||||
return { id: user.id, identifier: user.identifier, role: user.role, auth_provider: user.auth_provider ?? 'local' }
|
return {
|
||||||
|
id: user.id,
|
||||||
|
identifier: user.identifier,
|
||||||
|
role: user.role,
|
||||||
|
auth_provider: user.auth_provider ?? 'local',
|
||||||
|
avatar_url: user.avatar_path ? '/api/me/avatar' : null,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
14
server/api/me/avatar.delete.js
Normal file
14
server/api/me/avatar.delete.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { unlink } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { getDb, getAvatarsDir } from '../../utils/db.js'
|
||||||
|
import { requireAuth } from '../../utils/authHelpers.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = requireAuth(event)
|
||||||
|
if (!user.avatar_path) return { ok: true }
|
||||||
|
const path = join(getAvatarsDir(), user.avatar_path)
|
||||||
|
await unlink(path).catch(() => {})
|
||||||
|
const { run } = await getDb()
|
||||||
|
await run('UPDATE users SET avatar_path = NULL WHERE id = ?', [user.id])
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
23
server/api/me/avatar.get.js
Normal file
23
server/api/me/avatar.get.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { getAvatarsDir } from '../../utils/db.js'
|
||||||
|
import { requireAuth } from '../../utils/authHelpers.js'
|
||||||
|
|
||||||
|
const MIME = Object.freeze({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png' })
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = requireAuth(event)
|
||||||
|
if (!user.avatar_path) throw createError({ statusCode: 404, message: 'No avatar' })
|
||||||
|
const path = join(getAvatarsDir(), user.avatar_path)
|
||||||
|
const ext = user.avatar_path.split('.').pop()?.toLowerCase()
|
||||||
|
const mime = MIME[ext] ?? 'application/octet-stream'
|
||||||
|
try {
|
||||||
|
const buf = await readFile(path)
|
||||||
|
setResponseHeader(event, 'Content-Type', mime)
|
||||||
|
setResponseHeader(event, 'Cache-Control', 'private, max-age=3600')
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
throw createError({ statusCode: 404, message: 'Avatar not found' })
|
||||||
|
}
|
||||||
|
})
|
||||||
32
server/api/me/avatar.put.js
Normal file
32
server/api/me/avatar.put.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { writeFile, unlink } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { readMultipartFormData } from 'h3'
|
||||||
|
import { getDb, getAvatarsDir } from '../../utils/db.js'
|
||||||
|
import { requireAuth } from '../../utils/authHelpers.js'
|
||||||
|
|
||||||
|
const MAX_SIZE = 2 * 1024 * 1024
|
||||||
|
const ALLOWED_TYPES = Object.freeze(['image/jpeg', 'image/png'])
|
||||||
|
const EXT_BY_MIME = Object.freeze({ 'image/jpeg': 'jpg', 'image/png': 'png' })
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = requireAuth(event)
|
||||||
|
const form = await readMultipartFormData(event)
|
||||||
|
const file = form?.find(f => f.name === 'avatar' && f.data)
|
||||||
|
if (!file || !file.filename) throw createError({ statusCode: 400, message: 'Missing avatar file' })
|
||||||
|
if (file.data.length > MAX_SIZE) throw createError({ statusCode: 400, message: 'File too large' })
|
||||||
|
const mime = file.type ?? ''
|
||||||
|
if (!ALLOWED_TYPES.includes(mime)) throw createError({ statusCode: 400, message: 'Invalid type; use JPEG or PNG' })
|
||||||
|
const ext = EXT_BY_MIME[mime] ?? 'jpg'
|
||||||
|
const filename = `${user.id}.${ext}`
|
||||||
|
const dir = getAvatarsDir()
|
||||||
|
const path = join(dir, filename)
|
||||||
|
await writeFile(path, file.data)
|
||||||
|
const { run } = await getDb()
|
||||||
|
const previous = user.avatar_path
|
||||||
|
await run('UPDATE users SET avatar_path = ? WHERE id = ?', [filename, user.id])
|
||||||
|
if (previous && previous !== filename) {
|
||||||
|
const oldPath = join(dir, previous)
|
||||||
|
await unlink(oldPath).catch(() => {})
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
@@ -10,10 +10,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
const { get } = await getDb()
|
const { get } = await getDb()
|
||||||
const session = await get('SELECT user_id, expires_at FROM sessions WHERE id = ?', [sid])
|
const session = await get('SELECT user_id, expires_at FROM sessions WHERE id = ?', [sid])
|
||||||
if (!session || new Date(session.expires_at) < new Date()) return
|
if (!session || new Date(session.expires_at) < new Date()) return
|
||||||
const user = await get('SELECT id, identifier, role, auth_provider FROM users WHERE id = ?', [session.user_id])
|
const user = await get('SELECT id, identifier, role, auth_provider, avatar_path FROM users WHERE id = ?', [session.user_id])
|
||||||
if (user) {
|
if (user) {
|
||||||
const authProvider = user.auth_provider ?? 'local'
|
const authProvider = user.auth_provider ?? 'local'
|
||||||
event.context.user = { id: user.id, identifier: user.identifier, role: user.role, auth_provider: authProvider }
|
event.context.user = {
|
||||||
|
id: user.id,
|
||||||
|
identifier: user.identifier,
|
||||||
|
role: user.role,
|
||||||
|
auth_provider: authProvider,
|
||||||
|
avatar_path: user.avatar_path ?? null,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { join } from 'node:path'
|
import { join, dirname } from 'node:path'
|
||||||
import { mkdirSync, existsSync } from 'node:fs'
|
import { mkdirSync, existsSync } from 'node:fs'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
@@ -7,7 +7,7 @@ import { bootstrapAdmin } from './bootstrap.js'
|
|||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
const sqlite3 = require('sqlite3')
|
const sqlite3 = require('sqlite3')
|
||||||
|
|
||||||
const SCHEMA_VERSION = 2
|
const SCHEMA_VERSION = 3
|
||||||
const DB_BUSY_TIMEOUT_MS = 5000
|
const DB_BUSY_TIMEOUT_MS = 5000
|
||||||
|
|
||||||
let dbInstance = null
|
let dbInstance = null
|
||||||
@@ -68,6 +68,12 @@ const getDbPath = () => {
|
|||||||
return join(dir, 'kestrelos.db')
|
return join(dir, 'kestrelos.db')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getAvatarsDir = () => {
|
||||||
|
const dir = join(dirname(getDbPath()), 'avatars')
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
const getSchemaVersion = async (get) => {
|
const getSchemaVersion = async (get) => {
|
||||||
try {
|
try {
|
||||||
const row = await get('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1')
|
const row = await get('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1')
|
||||||
@@ -99,6 +105,12 @@ const migrateToV2 = async (run, all) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const migrateToV3 = async (run, all) => {
|
||||||
|
const info = await all('PRAGMA table_info(users)')
|
||||||
|
if (info.some(c => c.name === 'avatar_path')) return
|
||||||
|
await run('ALTER TABLE users ADD COLUMN avatar_path TEXT')
|
||||||
|
}
|
||||||
|
|
||||||
const runMigrations = async (run, all, get) => {
|
const runMigrations = async (run, all, get) => {
|
||||||
const version = await getSchemaVersion(get)
|
const version = await getSchemaVersion(get)
|
||||||
if (version >= SCHEMA_VERSION) return
|
if (version >= SCHEMA_VERSION) return
|
||||||
@@ -106,6 +118,10 @@ const runMigrations = async (run, all, get) => {
|
|||||||
await migrateToV2(run, all)
|
await migrateToV2(run, all)
|
||||||
await setSchemaVersion(run, 2)
|
await setSchemaVersion(run, 2)
|
||||||
}
|
}
|
||||||
|
if (version < 3) {
|
||||||
|
await migrateToV3(run, all)
|
||||||
|
await setSchemaVersion(run, 3)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const initDb = async (db, run, all, get) => {
|
const initDb = async (db, run, all, get) => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test.describe('Live Streaming E2E', () => {
|
|||||||
await loginAsAdmin(page, TEST_ADMIN.identifier, TEST_ADMIN.password)
|
await loginAsAdmin(page, TEST_ADMIN.identifier, TEST_ADMIN.password)
|
||||||
await page.goto('/cameras')
|
await page.goto('/cameras')
|
||||||
await page.waitForLoadState('domcontentloaded')
|
await page.waitForLoadState('domcontentloaded')
|
||||||
await expect(page.getByRole('heading', { name: 'Cameras' })).toBeVisible({ timeout: 10000 })
|
await expect(page.getByRole('heading', { name: 'Cameras', exact: true })).toBeVisible({ timeout: 10000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('publisher only: start sharing and reach Live', async ({ browser, browserName }) => {
|
test('publisher only: start sharing and reach Live', async ({ browser, browserName }) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'
|
|||||||
import NavDrawer from '../../app/components/NavDrawer.vue'
|
import NavDrawer from '../../app/components/NavDrawer.vue'
|
||||||
|
|
||||||
const withAuth = () => {
|
const withAuth = () => {
|
||||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member' }), { method: 'GET' })
|
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('NavDrawer', () => {
|
describe('NavDrawer', () => {
|
||||||
@@ -32,7 +32,6 @@ describe('NavDrawer', () => {
|
|||||||
})
|
})
|
||||||
expect(document.body.textContent).toContain('Map')
|
expect(document.body.textContent).toContain('Map')
|
||||||
expect(document.body.textContent).toContain('Settings')
|
expect(document.body.textContent).toContain('Settings')
|
||||||
expect(document.body.textContent).toContain('Navigation')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits update:modelValue when close is triggered', async () => {
|
it('emits update:modelValue when close is triggered', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import DefaultLayout from '../../app/layouts/default.vue'
|
|||||||
import NavDrawer from '../../app/components/NavDrawer.vue'
|
import NavDrawer from '../../app/components/NavDrawer.vue'
|
||||||
|
|
||||||
const withAuth = () => {
|
const withAuth = () => {
|
||||||
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member' }), { method: 'GET' })
|
registerEndpoint('/api/me', () => ({ id: '1', identifier: 'user', role: 'member', avatar_url: null }), { method: 'GET' })
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('default layout', () => {
|
describe('default layout', () => {
|
||||||
@@ -12,10 +12,9 @@ describe('default layout', () => {
|
|||||||
withAuth()
|
withAuth()
|
||||||
const wrapper = await mountSuspended(DefaultLayout)
|
const wrapper = await mountSuspended(DefaultLayout)
|
||||||
expect(wrapper.text()).toContain('KestrelOS')
|
expect(wrapper.text()).toContain('KestrelOS')
|
||||||
expect(wrapper.text()).toContain('Tactical Operations Center')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders drawer toggle with accessible label', async () => {
|
it('renders drawer toggle with accessible label on mobile', async () => {
|
||||||
withAuth()
|
withAuth()
|
||||||
const wrapper = await mountSuspended(DefaultLayout)
|
const wrapper = await mountSuspended(DefaultLayout)
|
||||||
const toggle = wrapper.find('button[aria-label="Toggle navigation"]')
|
const toggle = wrapper.find('button[aria-label="Toggle navigation"]')
|
||||||
@@ -28,14 +27,19 @@ describe('default layout', () => {
|
|||||||
expect(wrapper.findComponent(NavDrawer).exists()).toBe(true)
|
expect(wrapper.findComponent(NavDrawer).exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls logout and navigates when Logout is clicked', async () => {
|
it('renders user menu and sign out navigates home', async () => {
|
||||||
withAuth()
|
withAuth()
|
||||||
registerEndpoint('/api/auth/logout', () => null, { method: 'POST' })
|
registerEndpoint('/api/auth/logout', () => null, { method: 'POST' })
|
||||||
const wrapper = await mountSuspended(DefaultLayout)
|
const wrapper = await mountSuspended(DefaultLayout)
|
||||||
await new Promise(r => setTimeout(r, 100))
|
await new Promise(r => setTimeout(r, 100))
|
||||||
const logoutBtn = wrapper.findAll('button').find(b => b.text().includes('Logout'))
|
const menuTrigger = wrapper.find('button[aria-label="User menu"]')
|
||||||
expect(logoutBtn).toBeDefined()
|
expect(menuTrigger.exists()).toBe(true)
|
||||||
await logoutBtn.trigger('click')
|
await menuTrigger.trigger('click')
|
||||||
|
await new Promise(r => setTimeout(r, 50))
|
||||||
|
const signOut = wrapper.find('button[role="menuitem"]')
|
||||||
|
expect(signOut.exists()).toBe(true)
|
||||||
|
expect(signOut.text()).toContain('Sign out')
|
||||||
|
await signOut.trigger('click')
|
||||||
await new Promise(r => setTimeout(r, 100))
|
await new Promise(r => setTimeout(r, 100))
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
|
|||||||
Reference in New Issue
Block a user