feature/admin-dropdown-nav #9
6 changed files with 137 additions and 18 deletions
|
|
@ -87,3 +87,4 @@
|
|||
- Footer now includes a minimal cookie usage statement (essential cookies only; site requires acceptance).
|
||||
- Forgejo deployment scaffolding added: Docker Compose + runner config guidance and Apache vhost for git.halla-aho.net, plus CI workflow placeholder under `.forgejo/workflows/`.
|
||||
- Amenities: added separate EV charging flags (on-site vs nearby) plus wheelchair accessibility, including browse filters and admin approvals view badges.
|
||||
- Navbar: combined admin actions (approvals/users/monitoring) under a single “Admin” dropdown menu.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState, type SVGProps } from 'react';
|
||||
import { useEffect, useRef, useState, type SVGProps } from 'react';
|
||||
import { useI18n } from './I18nProvider';
|
||||
import logo from '../../img/logo.png';
|
||||
|
||||
|
|
@ -91,6 +91,13 @@ function Icon({ name }: { name: string }) {
|
|||
<path d="M9 14l2-3 2 2 2-4" />
|
||||
</svg>
|
||||
);
|
||||
case 'admin':
|
||||
return (
|
||||
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
||||
<path d="M12 2l7 4v6c0 5-3 9-7 10-4-1-7-5-7-10V6l7-4z" />
|
||||
<path d="M9.5 12.5l1.8 1.8 3.7-3.7" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -100,6 +107,8 @@ export default function NavBar() {
|
|||
const { t, locale, setLocale } = useI18n();
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [pendingCount, setPendingCount] = useState<number>(0);
|
||||
const [adminMenuOpen, setAdminMenuOpen] = useState(false);
|
||||
const adminMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
async function loadUser() {
|
||||
try {
|
||||
|
|
@ -116,6 +125,10 @@ export default function NavBar() {
|
|||
loadUser();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) setAdminMenuOpen(false);
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
const role = user?.role;
|
||||
const canSeeApprovals = role === 'ADMIN' || role === 'LISTING_MODERATOR' || role === 'USER_MODERATOR';
|
||||
|
|
@ -144,6 +157,26 @@ export default function NavBar() {
|
|||
const isListingMod = user?.role === 'LISTING_MODERATOR';
|
||||
const isUserMod = user?.role === 'USER_MODERATOR';
|
||||
const showApprovals = Boolean(user && (isAdmin || isListingMod || isUserMod));
|
||||
const showAdminMenu = Boolean(user && (showApprovals || isAdmin));
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminMenuOpen) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (target && adminMenuRef.current && !adminMenuRef.current.contains(target)) {
|
||||
setAdminMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setAdminMenuOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [adminMenuOpen]);
|
||||
|
||||
return (
|
||||
<header style={{ padding: '12px 20px', borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
|
|
@ -171,27 +204,47 @@ export default function NavBar() {
|
|||
<Link href="/listings/new" className="button secondary">
|
||||
<Icon name="plus" /> {t('navNewListing')}
|
||||
</Link>
|
||||
{showAdminMenu ? (
|
||||
<div className="nav-admin" ref={adminMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={adminMenuOpen}
|
||||
onClick={() => setAdminMenuOpen((v) => !v)}
|
||||
>
|
||||
<Icon name="admin" /> {t('navAdmin')}
|
||||
{showApprovals && pendingCount > 0 ? (
|
||||
<span className="nav-admin-badge" aria-label={t('approvalsPending', { count: pendingCount })}>
|
||||
{t('approvalsBadge', { count: pendingCount })}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{adminMenuOpen ? (
|
||||
<div className="nav-admin-menu" role="menu" aria-label={t('navAdmin')}>
|
||||
{showApprovals ? (
|
||||
<>
|
||||
<Link href="/admin/pending" className="button secondary">
|
||||
<Link href="/admin/pending" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}>
|
||||
<Icon name="check" /> {t('navApprovals')}
|
||||
{pendingCount > 0 ? (
|
||||
<span style={{ marginLeft: 6, background: '#ff7043', color: '#fff', borderRadius: 10, padding: '2px 6px', fontSize: 12 }}>
|
||||
<span className="nav-admin-badge" aria-label={t('approvalsPending', { count: pendingCount })}>
|
||||
{t('approvalsBadge', { count: pendingCount })}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link href="/admin/users" className="button secondary">
|
||||
<Link href="/admin/users" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}>
|
||||
<Icon name="users" /> {t('navUsers')}
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link href="/admin/monitor" className="button secondary">
|
||||
<Link href="/admin/monitor" className="nav-admin-item button secondary" role="menuitem" onClick={() => setAdminMenuOpen(false)}>
|
||||
<Icon name="monitor" /> {t('navMonitoring')}
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<button className="button secondary" onClick={logout}>
|
||||
<Icon name="logout" /> {t('navLogout')}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,41 @@ p {
|
|||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.nav-admin {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.nav-admin-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
min-width: 220px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.nav-admin-item.button.secondary {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.nav-admin-badge {
|
||||
margin-left: 6px;
|
||||
background: #ff7043;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
|
|
|
|||
|
|
@ -24,3 +24,4 @@ Deploy commands
|
|||
Notes
|
||||
- Ensure `deploy/.last-image` exists (run `deploy/build.sh` first).
|
||||
- `AUTH_SECRET`/`DATABASE_URL` should be in your env or loaded via `scripts/load-secrets.sh`.
|
||||
- `deploy/deploy.sh` runs `prisma migrate deploy` automatically when `DATABASE_URL` is set; if it isn't, it will try to read `DATABASE_URL` from the in-cluster `lomavuokraus-web-secrets` in the target namespace (recommended for test/staging/prod).
|
||||
|
|
|
|||
|
|
@ -71,6 +71,32 @@ APP_VERSION="${APP_VERSION:-$(echo \"$IMAGE\" | awk -F: '{print $NF}')}"
|
|||
|
||||
export K8S_NAMESPACE APP_HOST API_HOST NEXT_PUBLIC_SITE_URL NEXT_PUBLIC_API_BASE APP_ENV CLUSTER_ISSUER INGRESS_CLASS APP_REPLICAS K8S_IMAGE APP_VERSION
|
||||
|
||||
maybe_run_prisma_migrations() {
|
||||
local db_url="${DATABASE_URL:-}"
|
||||
if [[ -z "$db_url" ]]; then
|
||||
# If DATABASE_URL isn't available locally, try to reuse the in-cluster secret.
|
||||
# This prevents "works in cluster but deploy skipped migrations" drift.
|
||||
if command -v kubectl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then
|
||||
if kubectl -n "$K8S_NAMESPACE" get secret lomavuokraus-web-secrets >/dev/null 2>&1; then
|
||||
db_url="$(
|
||||
kubectl -n "$K8S_NAMESPACE" get secret lomavuokraus-web-secrets -o json \
|
||||
| jq -r '.data.DATABASE_URL // empty' \
|
||||
| base64 -d 2>/dev/null || true
|
||||
)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$db_url" ]]; then
|
||||
echo "Running Prisma migrations for APP_ENV=$APP_ENV (namespace=$K8S_NAMESPACE)"
|
||||
DATABASE_URL="$db_url" npx prisma migrate deploy
|
||||
else
|
||||
echo "DATABASE_URL not set and lomavuokraus-web-secrets/DATABASE_URL not found; skipping Prisma migrations" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
maybe_run_prisma_migrations
|
||||
|
||||
TMP_MANIFEST=$(mktemp)
|
||||
envsubst < k8s/app.yaml > "$TMP_MANIFEST"
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const baseMessages = {
|
|||
navProfile: 'Profile',
|
||||
navMyListings: 'My listings',
|
||||
navNewListing: 'New listing',
|
||||
navAdmin: 'Admin',
|
||||
navApprovals: 'Approvals',
|
||||
navUsers: 'Users',
|
||||
navMonitoring: 'Monitoring',
|
||||
|
|
@ -325,6 +326,7 @@ const baseMessages = {
|
|||
navProfile: 'Profiili',
|
||||
navMyListings: 'Omat kohteet',
|
||||
navNewListing: 'Luo kohde',
|
||||
navAdmin: 'Ylläpito',
|
||||
navApprovals: 'Tarkastettavat',
|
||||
navUsers: 'Käyttäjät',
|
||||
navMonitoring: 'Valvonta',
|
||||
|
|
@ -646,6 +648,7 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
|
|||
navProfile: 'Profil',
|
||||
navMyListings: 'Mina annonser',
|
||||
navNewListing: 'Ny annons',
|
||||
navAdmin: 'Admin',
|
||||
navApprovals: 'Godkännanden',
|
||||
navUsers: 'Användare',
|
||||
navMonitoring: 'Övervakning',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue