feat: /search

This commit is contained in:
kittentm 2026-08-03 04:23:33 +02:00
commit 82ecb49292
Signed by: kitten
GPG key ID: 394B4EABE4405A83
3 changed files with 281 additions and 0 deletions

View file

@ -38,6 +38,10 @@ const emit = defineEmits(['logout', 'navigate'])
@click="emit('navigate', '/')">
<span>Welcome</span>
</button>
<button type="button" class="nav-item" :class="{ 'active-nav': activeTab === 'search' }"
@click="emit('navigate', '/search')">
<span>Search Users</span>
</button>
<button type="button" class="nav-item logout-btn" @click="emit('logout')">
<span>Sign Out</span>
</button>

207
app/pages/search/index.vue Normal file
View file

@ -0,0 +1,207 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useAuth } from '~/composables/useAuth.js'
const {
isAuthenticated,
isInitialized,
userProfile,
verifySession,
silentRefresh,
logout
} = useAuth()
const searchQuery = ref('')
const searchResults = ref([])
const isSearching = ref(false)
const hasSearched = ref(false)
const errorMessage = ref('')
// pagination state
const currentPage = ref(1)
const totalPages = ref(0)
const totalResults = ref(0)
const limit = 10
const handleLogout = () => {
logout()
}
const handleNavigate = (path) => {
navigateTo(path)
}
const executeSearch = async (page = 1) => {
const query = searchQuery.value.trim()
if (!query) return
isSearching.value = true
hasSearched.value = true
errorMessage.value = ''
try {
const res = await $fetch(`/api/admin/users/search`, {
params: {
q: query,
page,
limit
}
})
searchResults.value = res.data || []
currentPage.value = res.pagination.page
totalPages.value = res.pagination.totalPages
totalResults.value = res.pagination.total
} catch (err) {
errorMessage.value = err.data?.statusMessage || err.data?.message || 'Failed to search database.'
searchResults.value = []
totalPages.value = 0
totalResults.value = 0
} finally {
isSearching.value = false
}
}
const handleSearchSubmit = () => {
currentPage.value = 1
executeSearch(1)
}
const goToPage = (page) => {
if (page < 1 || page > totalPages.value || isSearching.value) return
executeSearch(page)
}
onMounted(async () => {
if (isAuthenticated.value) {
const valid = await verifySession()
if (!valid) {
navigateTo('/oauth')
}
} else {
const success = await silentRefresh()
if (!success) {
navigateTo('/oauth')
}
}
})
</script>
<template>
<div>
<div v-if="!isInitialized" class="global-loader-wrapper">
<div class="spinner lg"></div>
<p style="color: #8b949e; margin-top: 1rem; font-size: 0.95rem;">
Connecting to Keycloak...
</p>
</div>
<div v-else-if="isAuthenticated" class="settings-container">
<Header
:user-profile="userProfile"
active-tab="search"
@logout="handleLogout"
@navigate="handleNavigate"
/>
<main class="settings-main-content">
<section class="settings-panel" style="width: 100%;">
<div class="panel-header archivo-black">
<span>SEARCH USERS</span>
</div>
<div class="panel-body" style="padding: 1.5rem;">
<form @submit.prevent="handleSearchSubmit" style="display: flex; gap: 0.75rem; margin-bottom: 1.5rem;">
<input
v-model="searchQuery"
type="text"
placeholder="Search by SFID, PID, or Email"
required
style="flex: 1; padding: 0.65rem 0.85rem; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.95rem; outline: none;"
/>
<button
type="submit"
class="btn-login-action"
style="padding: 0.65rem 1.25rem;"
:disabled="isSearching"
>
<span v-if="isSearching" class="spinner sm btn-spinner"></span>
<span style="vertical-align: middle;">
{{ isSearching ? 'Searching...' : 'Search' }}
</span>
</button>
</form>
<p v-if="errorMessage" style="color: #f85149; font-size: 0.85rem; margin-bottom: 1rem; text-align: center;">
{{ errorMessage }}
</p>
<div v-if="hasSearched && !isSearching && searchResults.length > 0" style="margin-bottom: 1rem; font-size: 0.85rem; color: #8b949e;">
Showing {{ (currentPage - 1) * limit + 1 }}{{ Math.min(currentPage * limit, totalResults) }} of {{ totalResults }} results
</div>
<div v-if="searchResults.length > 0" class="results-list" style="display: flex; flex-direction: column; gap: 0.75rem;">
<div
v-for="user in searchResults"
:key="user.id"
style="display: flex; align-items: center; justify-content: space-between; padding: 0.85rem 1rem; background: #0d1117; border: 1px solid #30363d; border-radius: 8px;"
>
<div style="display: flex; align-items: center; gap: 0.85rem;">
<img
:src="user.pid ? `https://mii.spfn.net/${user.pid}/main.png` : '/img_unknown_MiiIcon.png'"
:alt="user.sfid || 'Mii Avatar'"
style="width: 44px; height: 44px; border-radius: 50%; background: #161b22; object-fit: contain; flex-shrink: 0;"
@error="(e) => e.target.src = '/img_unknown_MiiIcon.png'"
/>
<div style="display: flex; flex-direction: column;">
<span style="font-weight: 600; color: #f0f6fc; font-size: 0.95rem;">
{{ user.sfid || 'No SFID Linked' }}
</span>
<span style="font-size: 0.8rem; color: #8b949e; margin-top: 0.15rem;">
PID: {{ user.pid || 'N/A' }}
</span>
</div>
</div>
<div v-if="user.email" style="font-size: 0.85rem; color: #8b949e;">
{{ user.email }}
</div>
</div>
</div>
<div v-else-if="hasSearched && !isSearching" style="text-align: center; color: #8b949e; padding: 2rem 0; font-size: 0.9rem;">
No users found matching "{{ searchQuery }}"
</div>
<div
v-if="totalPages > 1"
style="display: flex; align-items: center; justify-content: space-between; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #30363d;"
>
<button
type="button"
style="padding: 0.5rem 1rem; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.85rem; cursor: pointer;"
:disabled="currentPage === 1 || isSearching"
@click="goToPage(currentPage - 1)"
>
Previous
</button>
<span style="font-size: 0.85rem; color: #8b949e;">
Page {{ currentPage }} of {{ totalPages }}
</span>
<button
type="button"
style="padding: 0.5rem 1rem; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.85rem; cursor: pointer;"
:disabled="currentPage === totalPages || isSearching"
@click="goToPage(currentPage + 1)"
>
Next
</button>
</div>
</div>
</section>
</main>
</div>
</div>
</template>

View file

@ -0,0 +1,70 @@
import { queryNnas } from '../../../utils/nnasDb'
export default defineEventHandler(async (event) => {
const queryParams = getQuery(event)
const searchTerm = String(queryParams.q || '').trim()
const page = Math.max(1, parseInt(String(queryParams.page || '1'), 10))
const limit = Math.max(1, Math.min(50, parseInt(String(queryParams.limit || '10'), 10)))
const offset = (page - 1) * limit
if (!searchTerm) {
return {
data: [],
pagination: { total: 0, page, limit, totalPages: 0 }
}
}
try {
const wildPattern = `%${searchTerm}%`
const startsWithPattern = `${searchTerm}%`
const countResult = await queryNnas(
`SELECT COUNT(*) AS total
FROM users
WHERE username ILIKE $1
OR CAST(pid AS TEXT) ILIKE $1
OR email ILIKE $1`,
[wildPattern]
)
const total = parseInt(countResult.rows[0]?.total || '0', 10)
const totalPages = Math.ceil(total / limit)
const dataResult = await queryNnas(
`SELECT
pid AS id,
username AS sfid,
pid,
email
FROM users
WHERE username ILIKE $1
OR CAST(pid AS TEXT) ILIKE $1
OR email ILIKE $1
ORDER BY
CASE
WHEN LOWER(username) = LOWER($2) OR CAST(pid AS TEXT) = $2 THEN 0
WHEN username ILIKE $3 OR email ILIKE $3 OR CAST(pid AS TEXT) ILIKE $3 THEN 1
ELSE 2
END,
pid DESC
LIMIT $4 OFFSET $5`,
[wildPattern, searchTerm, startsWithPattern, limit, offset]
)
return {
data: dataResult.rows,
pagination: {
total,
page,
limit,
totalPages
}
}
} catch (error: any) {
console.error('[NNAS DB SEARCH ERROR]:', error)
throw createError({
statusCode: 500,
statusMessage: `NNAS Database query failed`
})
}
})