Phase 2: multi-tab dashboard shell + user management

- Restructure /admin into a (dashboard) route group with a sidebar shell
  (Galerija / Storitve / Novice / Kontakt / Uporabniki) and active-tab state
- Hide the public site header/footer on all /admin routes
- Centralize auth in hooks.server.ts so page loads, form actions AND endpoints
  are all gated (layout load guards don't cover actions)
- Users section (admin-only): add user, change role, reset password, delete;
  safeguards prevent self-delete and removing the last admin; server-side role
  checks (editors get 403 even if UI hidden)
- Login redirects already-authed users; logout moved to /admin/logout endpoint
- Gallery management moved under /admin/gallery; shared .admin-* styles in app.css
- Fix flexbox overflow in the dashboard main column (min-width:0, box-sizing)
- Placeholder tabs for Services/News/Contact (Phases 3-5)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matic Ivešić 2026-08-02 21:13:02 +02:00
parent 6bc5fffda2
commit 22af32c722
19 changed files with 825 additions and 264 deletions

View File

@ -20,4 +20,86 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
}
/* ---- Shared admin dashboard UI ---- */
.admin-section {
display: flex;
flex-direction: column;
gap: 1.2em;
}
.admin-section h1 {
margin: 0;
font-weight: 600;
font-size: 1.5em;
}
.admin-section h2 {
margin: 0 0 0.3em;
font-weight: 600;
font-size: 1.1em;
}
.admin-card {
background: #11161a;
border: 1px solid rgba(229, 228, 234, 0.12);
border-radius: 12px;
padding: 1.4em;
display: flex;
flex-direction: column;
gap: 0.9em;
}
.admin-card label {
display: flex;
flex-direction: column;
gap: 0.35em;
font-size: 0.9em;
color: #c7c9cf;
}
.admin-input {
background: #0a0d0f;
border: 1px solid rgba(229, 228, 234, 0.2);
border-radius: 8px;
padding: 0.6em 0.7em;
color: #e5e4ea;
font-family: inherit;
font-size: 1em;
width: 100%;
box-sizing: border-box;
}
.admin-btn {
background: #42af38;
color: #06210a;
border: none;
border-radius: 8px;
padding: 0.55em 1.1em;
font-weight: 600;
font-family: inherit;
font-size: 0.95em;
cursor: pointer;
align-self: flex-start;
}
.admin-btn:disabled {
opacity: 0.6;
cursor: default;
}
.admin-btn-danger {
background: rgba(220, 40, 40, 0.9);
color: #fff;
}
.admin-btn-ghost {
background: transparent;
color: #e5e4ea;
border: 1px solid rgba(229, 228, 234, 0.3);
}
.admin-msg {
margin: 0;
font-size: 0.9em;
color: #42af38;
}
.admin-error {
margin: 0;
font-size: 0.9em;
color: #ff6b6b;
}
.admin-notice {
color: #9aa0a6;
} }

View File

@ -1,4 +1,4 @@
import type { Handle } from '@sveltejs/kit'; import { redirect, type Handle } from '@sveltejs/kit';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { SESSION_COOKIE, validateSessionToken } from '$lib/server/auth'; import { SESSION_COOKIE, validateSessionToken } from '$lib/server/auth';
@ -28,5 +28,13 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.session = null; event.locals.session = null;
} }
// Protect the whole dashboard here (except the login page) so that page
// loads, form actions AND endpoints are all gated — layout `load` guards do
// not run before form actions.
const { pathname } = event.url;
if (pathname.startsWith('/admin') && pathname !== '/admin/login' && !event.locals.user) {
throw redirect(303, '/admin/login');
}
return resolve(event); return resolve(event);
}; };

62
src/lib/server/users.ts Normal file
View File

@ -0,0 +1,62 @@
import { count, eq } from 'drizzle-orm';
import { randomUUID } from 'node:crypto';
import { db } from './db';
import { users, type PublicUser } from './db/schema';
import { hashPassword } from './auth';
export type Role = 'admin' | 'editor';
export function parseRole(value: FormDataEntryValue | null): Role | null {
return value === 'admin' || value === 'editor' ? value : null;
}
export function listUsers(): PublicUser[] {
return db
.select({ id: users.id, email: users.email, name: users.name, role: users.role })
.from(users)
.orderBy(users.createdAt)
.all();
}
export function getUser(id: string) {
return db.select().from(users).where(eq(users.id, id)).get();
}
export function getUserByEmail(email: string) {
return db.select().from(users).where(eq(users.email, email.toLowerCase())).get();
}
export function countAdmins(): number {
return db.select({ c: count() }).from(users).where(eq(users.role, 'admin')).get()?.c ?? 0;
}
export async function createUser(input: {
email: string;
name: string;
password: string;
role: Role;
}): Promise<void> {
const passwordHash = await hashPassword(input.password);
db.insert(users)
.values({
id: randomUUID(),
email: input.email.toLowerCase(),
name: input.name,
role: input.role,
passwordHash
})
.run();
}
export function updateUserRole(id: string, role: Role): void {
db.update(users).set({ role }).where(eq(users.id, id)).run();
}
export async function setUserPassword(id: string, password: string): Promise<void> {
const passwordHash = await hashPassword(password);
db.update(users).set({ passwordHash }).where(eq(users.id, id)).run();
}
export function deleteUser(id: string): void {
db.delete(users).where(eq(users.id, id)).run();
}

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import intelidom_icon from '$lib/assets/intelidom_icon.svg'; import intelidom_icon from '$lib/assets/intelidom_icon.svg';
import '../app.css'; import '../app.css';
@ -7,6 +8,10 @@
import Footer from '$lib/Footer.svelte'; import Footer from '$lib/Footer.svelte';
let { children } = $props(); let { children } = $props();
// The admin dashboard provides its own chrome, so hide the public site
// header/footer on every /admin route (including the login page).
const isAdmin = $derived(page.url.pathname.startsWith('/admin'));
</script> </script>
<svelte:head> <svelte:head>
@ -14,10 +19,14 @@
<link rel="icon" href={intelidom_icon} /> <link rel="icon" href={intelidom_icon} />
</svelte:head> </svelte:head>
<Header></Header> {#if !isAdmin}
<Header></Header>
{/if}
<div class="app-content"> <div class="app-content">
{@render children?.()} {@render children?.()}
</div> </div>
<Footer></Footer> {#if !isAdmin}
<Footer></Footer>
{/if}

View File

@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => {
// hooks.server.ts already blocks unauthenticated access; this also narrows the type.
if (!locals.user) {
throw redirect(303, '/admin/login');
}
return {
user: {
id: locals.user.id,
email: locals.user.email,
name: locals.user.name,
role: locals.user.role
}
};
};

View File

@ -0,0 +1,200 @@
<script lang="ts">
import { page } from '$app/state';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
const tabs = [
{ href: '/admin/gallery', label: 'Galerija' },
{ href: '/admin/services', label: 'Storitve' },
{ href: '/admin/news', label: 'Novice' },
{ href: '/admin/contact', label: 'Kontakt' }
];
const isAdmin = $derived(data.user.role === 'admin');
</script>
<svelte:head>
<title>Admin — InteliDom d.o.o.</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="dash">
<aside class="sidebar">
<div class="brand">
<a href="/">InteliDom</a>
<span>Admin</span>
</div>
<nav>
{#each tabs as tab (tab.href)}
<a href={tab.href} class="tab" class:active={page.url.pathname.startsWith(tab.href)}>
{tab.label}
</a>
{/each}
{#if isAdmin}
<a
href="/admin/users"
class="tab"
class:active={page.url.pathname.startsWith('/admin/users')}
>
Uporabniki
</a>
{/if}
</nav>
<div class="account">
<div class="who">
<span class="who-name">{data.user.name}</span>
<span class="who-role">{data.user.role}</span>
</div>
<form method="POST" action="/admin/logout">
<button type="submit" class="logout">Odjava</button>
</form>
</div>
</aside>
<main class="dash-main">
{@render children()}
</main>
</div>
<style>
.dash {
width: 100%;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.sidebar {
background: #11161a;
border-bottom: 1px solid rgba(229, 228, 234, 0.1);
display: flex;
flex-direction: column;
gap: 1em;
padding: 1em;
}
.brand {
display: flex;
align-items: baseline;
gap: 0.5em;
}
.brand a {
color: #42af38;
font-weight: 700;
font-size: 1.2em;
text-decoration: none;
}
.brand span {
color: #9aa0a6;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.08em;
}
nav {
display: flex;
flex-direction: row;
gap: 0.3em;
overflow-x: auto;
}
.tab {
color: #c7c9cf;
text-decoration: none;
padding: 0.5em 0.9em;
border-radius: 8px;
white-space: nowrap;
transition: background-color 0.15s ease, color 0.15s ease;
}
.tab:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.tab.active {
background: rgba(66, 175, 56, 0.15);
color: #fff;
}
.account {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
flex-wrap: wrap;
}
.who {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.who-name {
color: #e5e4ea;
font-weight: 600;
}
.who-role {
color: #9aa0a6;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.logout {
background: transparent;
color: #e5e4ea;
border: 1px solid rgba(229, 228, 234, 0.3);
border-radius: 8px;
padding: 0.5em 1em;
font-family: inherit;
font-size: 0.9em;
cursor: pointer;
}
.logout:hover {
background: rgba(255, 255, 255, 0.05);
}
.dash-main {
flex: 1 1 auto;
min-width: 0; /* allow the flex item to shrink so wide content wraps instead of overflowing */
box-sizing: border-box;
padding: 1.5em 1.5em 3em;
}
@media (min-width: 900px) {
.dash {
flex-direction: row;
}
.sidebar {
width: 220px;
flex-shrink: 0;
border-bottom: none;
border-right: 1px solid rgba(229, 228, 234, 0.1);
position: sticky;
top: 0;
height: 100vh;
}
nav {
flex-direction: column;
flex: 1;
overflow-x: visible;
}
.account {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// The dashboard landing page: send /admin to the first tab.
export const load: PageServerLoad = () => {
throw redirect(307, '/admin/gallery');
};

View File

@ -0,0 +1 @@
<!-- /admin redirects to /admin/gallery in +page.server.ts; this never renders. -->

View File

@ -0,0 +1,6 @@
<section class="admin-section">
<h1>Kontakt</h1>
<div class="admin-card">
<p class="admin-notice">Urejanje kontaktnih podatkov bo na voljo kmalu (Faza 5).</p>
</div>
</section>

View File

@ -1,11 +1,10 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { asc, desc, eq } from 'drizzle-orm'; import { asc, desc, eq } from 'drizzle-orm';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { galleryImages } from '$lib/server/db/schema'; import { galleryImages } from '$lib/server/db/schema';
import { saveImage, deleteUpload } from '$lib/server/storage'; import { saveImage, deleteUpload } from '$lib/server/storage';
import { SESSION_COOKIE, invalidateSession } from '$lib/server/auth';
function listImages() { function listImages() {
return db return db
@ -57,12 +56,5 @@ export const actions: Actions = {
} }
return { success: 'Slika izbrisana.' }; return { success: 'Slika izbrisana.' };
},
logout: async ({ cookies }) => {
const token = cookies.get(SESSION_COOKIE);
if (token) invalidateSession(token);
cookies.delete(SESSION_COOKIE, { path: '/' });
throw redirect(303, '/admin/login');
} }
}; };

View File

@ -0,0 +1,106 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let uploading = $state(false);
let fileInput: HTMLInputElement | undefined = $state();
</script>
<section class="admin-section">
<h1>Galerija</h1>
<form
class="admin-card"
method="POST"
action="?/upload"
enctype="multipart/form-data"
use:enhance={() => {
uploading = true;
return async ({ update }) => {
await update();
uploading = false;
if (fileInput) fileInput.value = '';
};
}}
>
<h2>Dodaj sliko</h2>
<input bind:this={fileInput} type="file" name="images" accept="image/*" multiple required />
<label>
Opis (neobvezno)
<input class="admin-input" type="text" name="caption" placeholder="npr. Montaža omarice" />
</label>
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
<button class="admin-btn" type="submit" disabled={uploading}>
{uploading ? 'Nalaganje…' : 'Naloži'}
</button>
</form>
<h2>Slike v galeriji</h2>
{#if data.images.length === 0}
<p class="admin-notice">Ni slik. Naložite prvo zgoraj.</p>
{:else}
<div class="grid">
{#each data.images as image (image.id)}
<div class="tile">
<img src={image.url} alt={image.caption ?? ''} loading="lazy" />
{#if image.caption}<span class="cap">{image.caption}</span>{/if}
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={image.id} />
<button class="del" type="submit" aria-label="Izbriši">Izbriši</button>
</form>
</div>
{/each}
</div>
{/if}
</section>
<style>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1em;
}
.tile {
position: relative;
border-radius: 10px;
overflow: hidden;
background: #11161a;
aspect-ratio: 4 / 3;
}
.tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tile .cap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 0.5em 0.6em;
font-size: 0.8em;
color: #fff;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
}
.tile .del {
position: absolute;
top: 0.5em;
right: 0.5em;
background: rgba(220, 40, 40, 0.9);
color: #fff;
border: none;
padding: 0.3em 0.6em;
font-size: 0.8em;
font-family: inherit;
border-radius: 6px;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,6 @@
<section class="admin-section">
<h1>Novice</h1>
<div class="admin-card">
<p class="admin-notice">Urejanje novic bo na voljo kmalu (Faza 4).</p>
</div>
</section>

View File

@ -0,0 +1,8 @@
<section class="admin-section">
<h1>Storitve</h1>
<div class="admin-card">
<p class="admin-notice">
Urejanje storitev bo na voljo kmalu (Faza 3). Trenutno so storitve zapisane v kodi.
</p>
</div>
</section>

View File

@ -0,0 +1,104 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
countAdmins,
createUser,
deleteUser,
getUser,
getUserByEmail,
listUsers,
parseRole,
setUserPassword,
updateUserRole
} from '$lib/server/users';
function requireAdmin(locals: App.Locals) {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Samo administratorji lahko upravljajo uporabnike.');
}
return locals.user;
}
export const load: PageServerLoad = ({ locals }) => {
// Editors don't manage users — send them back to the gallery.
if (!locals.user || locals.user.role !== 'admin') {
throw redirect(303, '/admin/gallery');
}
return { users: listUsers(), currentUserId: locals.user.id };
};
export const actions: Actions = {
create: async ({ request, locals }) => {
requireAdmin(locals);
const form = await request.formData();
const email = String(form.get('email') ?? '')
.trim()
.toLowerCase();
const name = String(form.get('name') ?? '').trim();
const password = String(form.get('password') ?? '');
const role = parseRole(form.get('role'));
if (!email || !name || !password || !role) {
return fail(400, { error: 'Izpolnite vsa polja.' });
}
if (password.length < 8) {
return fail(400, { error: 'Geslo mora imeti vsaj 8 znakov.' });
}
if (getUserByEmail(email)) {
return fail(400, { error: 'Uporabnik s tem e-naslovom že obstaja.' });
}
await createUser({ email, name, password, role });
return { success: `Uporabnik ${email} dodan.` };
},
setRole: async ({ request, locals }) => {
requireAdmin(locals);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const role = parseRole(form.get('role'));
if (!role) return fail(400, { error: 'Neveljavna vloga.' });
const target = getUser(id);
if (!target) return fail(404, { error: 'Uporabnik ne obstaja.' });
if (target.role === 'admin' && role === 'editor' && countAdmins() <= 1) {
return fail(400, { error: 'Vsaj en administrator mora ostati.' });
}
updateUserRole(id, role);
return { success: 'Vloga posodobljena.' };
},
resetPassword: async ({ request, locals }) => {
requireAdmin(locals);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const password = String(form.get('password') ?? '');
if (password.length < 8) {
return fail(400, { error: 'Geslo mora imeti vsaj 8 znakov.' });
}
if (!getUser(id)) return fail(404, { error: 'Uporabnik ne obstaja.' });
await setUserPassword(id, password);
return { success: 'Geslo posodobljeno.' };
},
delete: async ({ request, locals }) => {
const admin = requireAdmin(locals);
const form = await request.formData();
const id = String(form.get('id') ?? '');
if (id === admin.id) {
return fail(400, { error: 'Ne morete izbrisati lastnega računa.' });
}
const target = getUser(id);
if (!target) return fail(404, { error: 'Uporabnik ne obstaja.' });
if (target.role === 'admin' && countAdmins() <= 1) {
return fail(400, { error: 'Vsaj en administrator mora ostati.' });
}
deleteUser(id);
return { success: 'Uporabnik izbrisan.' };
}
};

View File

@ -0,0 +1,187 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
// Which user's "reset password" box is open.
let resettingId: string | null = $state(null);
</script>
<section class="admin-section">
<h1>Uporabniki</h1>
<form class="admin-card" method="POST" action="?/create" use:enhance>
<h2>Dodaj uporabnika</h2>
<div class="fields">
<label>
Ime
<input class="admin-input" type="text" name="name" required />
</label>
<label>
E-naslov
<input class="admin-input" type="email" name="email" required />
</label>
<label>
Geslo
<input class="admin-input" type="password" name="password" minlength="8" required />
</label>
<label>
Vloga
<select class="admin-input" name="role">
<option value="editor">Urejevalec</option>
<option value="admin">Administrator</option>
</select>
</label>
</div>
{#if form?.error}<p class="admin-error">{form.error}</p>{/if}
{#if form?.success}<p class="admin-msg">{form.success}</p>{/if}
<button class="admin-btn" type="submit">Dodaj</button>
</form>
<h2>Obstoječi uporabniki</h2>
<div class="users">
{#each data.users as u (u.id)}
<div class="user-row">
<div class="info">
<span class="name">{u.name}{#if u.id === data.currentUserId}<span class="you"> (vi)</span>{/if}</span>
<span class="email">{u.email}</span>
</div>
<div class="controls">
<form method="POST" action="?/setRole" use:enhance class="role-form">
<input type="hidden" name="id" value={u.id} />
<select class="admin-input role-select" name="role" value={u.role}>
<option value="editor">Urejevalec</option>
<option value="admin">Administrator</option>
</select>
<button class="admin-btn" type="submit">Shrani</button>
</form>
<button
class="admin-btn admin-btn-ghost"
type="button"
onclick={() => (resettingId = resettingId === u.id ? null : u.id)}
>
Geslo
</button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={u.id} />
<button
class="admin-btn admin-btn-danger"
type="submit"
disabled={u.id === data.currentUserId}
>
Izbriši
</button>
</form>
</div>
{#if resettingId === u.id}
<form
method="POST"
action="?/resetPassword"
use:enhance={() => async ({ update }) => {
await update();
resettingId = null;
}}
class="reset-form"
>
<input type="hidden" name="id" value={u.id} />
<input
class="admin-input"
type="password"
name="password"
placeholder="Novo geslo (min. 8 znakov)"
minlength="8"
required
/>
<button class="admin-btn" type="submit">Nastavi geslo</button>
</form>
{/if}
</div>
{/each}
</div>
</section>
<style>
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9em;
}
.users {
display: flex;
flex-direction: column;
gap: 0.6em;
}
.user-row {
background: #11161a;
border: 1px solid rgba(229, 228, 234, 0.12);
border-radius: 10px;
padding: 0.9em 1em;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.8em;
}
.info {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.name {
font-weight: 600;
}
.you {
color: #42af38;
font-weight: 400;
font-size: 0.85em;
}
.email {
color: #9aa0a6;
font-size: 0.85em;
}
.controls {
display: flex;
align-items: center;
gap: 0.5em;
flex-wrap: wrap;
}
.role-form {
display: flex;
gap: 0.4em;
}
.role-select {
width: auto;
padding: 0.4em 0.5em;
}
.reset-form {
flex-basis: 100%;
display: flex;
gap: 0.5em;
margin-top: 0.3em;
}
.reset-form .admin-input {
flex: 1;
}
@media (max-width: 620px) {
.fields {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,24 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals, url }) => {
const onLogin = url.pathname === '/admin/login';
if (!locals.user && !onLogin) {
throw redirect(303, '/admin/login');
}
if (locals.user && onLogin) {
throw redirect(303, '/admin');
}
return {
user: locals.user
? {
id: locals.user.id,
email: locals.user.email,
name: locals.user.name,
role: locals.user.role
}
: null
};
};

View File

@ -1,227 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let uploading = $state(false);
let fileInput: HTMLInputElement | undefined = $state();
</script>
<svelte:head>
<title>Admin — InteliDom d.o.o.</title>
<meta name="robots" content="noindex" />
</svelte:head>
<section class="admin">
<div class="bar">
<h1>Admin</h1>
<div class="bar-right">
{#if data.user}<span>Prijavljeni kot <strong>{data.user.name}</strong></span>{/if}
<form method="POST" action="?/logout" use:enhance>
<button class="ghost" type="submit">Odjava</button>
</form>
</div>
</div>
<form
class="card upload"
method="POST"
action="?/upload"
enctype="multipart/form-data"
use:enhance={() => {
uploading = true;
return async ({ update }) => {
await update();
uploading = false;
if (fileInput) fileInput.value = '';
};
}}
>
<h2>Dodaj sliko v galerijo</h2>
<input bind:this={fileInput} type="file" name="images" accept="image/*" multiple required />
<label>
Opis (neobvezno)
<input type="text" name="caption" placeholder="npr. Montaža omarice" />
</label>
{#if form?.error}<p class="error">{form.error}</p>{/if}
{#if form?.success}<p class="msg">{form.success}</p>{/if}
<button type="submit" disabled={uploading}>{uploading ? 'Nalaganje…' : 'Naloži'}</button>
</form>
<h2 class="section-title">Slike v galeriji</h2>
{#if data.images.length === 0}
<p class="notice">Ni slik. Naložite prvo zgoraj.</p>
{:else}
<div class="grid">
{#each data.images as image (image.id)}
<div class="tile">
<img src={image.url} alt={image.caption ?? ''} loading="lazy" />
{#if image.caption}<span class="cap">{image.caption}</span>{/if}
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={image.id} />
<button class="del" type="submit" aria-label="Izbriši">Izbriši</button>
</form>
</div>
{/each}
</div>
{/if}
</section>
<style>
.admin {
width: 100%;
max-width: 900px;
margin: 0 auto;
padding: 0 1em 3em;
display: flex;
flex-direction: column;
gap: 1.2em;
}
h1 {
margin: 0;
font-weight: 600;
}
h2 {
margin: 0 0 0.3em;
font-weight: 600;
font-size: 1.1em;
}
.notice {
color: #9aa0a6;
}
.bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1em;
flex-wrap: wrap;
color: #c7c9cf;
}
.bar-right {
display: flex;
align-items: center;
gap: 1em;
}
.card {
background: #11161a;
border: 1px solid rgba(229, 228, 234, 0.12);
border-radius: 12px;
padding: 1.4em;
display: flex;
flex-direction: column;
gap: 0.9em;
}
label {
display: flex;
flex-direction: column;
gap: 0.35em;
font-size: 0.9em;
color: #c7c9cf;
}
input[type='text'] {
background: #0a0d0f;
border: 1px solid rgba(229, 228, 234, 0.2);
border-radius: 8px;
padding: 0.6em 0.7em;
color: #e5e4ea;
font-family: inherit;
font-size: 1em;
}
input[type='file'] {
color: #c7c9cf;
font-family: inherit;
}
button {
background: #42af38;
color: #06210a;
border: none;
border-radius: 8px;
padding: 0.6em 1.1em;
font-weight: 600;
font-family: inherit;
font-size: 1em;
cursor: pointer;
align-self: flex-start;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
button.ghost {
background: transparent;
color: #e5e4ea;
border: 1px solid rgba(229, 228, 234, 0.3);
}
.section-title {
margin-top: 0.5em;
}
.error {
color: #ff6b6b;
margin: 0;
font-size: 0.9em;
}
.msg {
color: #42af38;
margin: 0;
font-size: 0.9em;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1em;
}
.tile {
position: relative;
border-radius: 10px;
overflow: hidden;
background: #11161a;
aspect-ratio: 4 / 3;
}
.tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tile .cap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 0.5em 0.6em;
font-size: 0.8em;
color: #fff;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
}
.tile .del {
position: absolute;
top: 0.5em;
right: 0.5em;
background: rgba(220, 40, 40, 0.9);
color: #fff;
padding: 0.3em 0.6em;
font-size: 0.8em;
border-radius: 6px;
}
</style>

View File

@ -1,8 +1,14 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import type { Actions } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { SESSION_COOKIE, createSession, findUserByEmail, verifyPassword } from '$lib/server/auth'; import { SESSION_COOKIE, createSession, findUserByEmail, verifyPassword } from '$lib/server/auth';
export const load: PageServerLoad = ({ locals }) => {
if (locals.user) {
throw redirect(303, '/admin');
}
};
export const actions: Actions = { export const actions: Actions = {
default: async ({ request, cookies }) => { default: async ({ request, cookies }) => {
const form = await request.formData(); const form = await request.formData();

View File

@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { SESSION_COOKIE, invalidateSession } from '$lib/server/auth';
export const POST: RequestHandler = ({ cookies }) => {
const token = cookies.get(SESSION_COOKIE);
if (token) invalidateSession(token);
cookies.delete(SESSION_COOKIE, { path: '/' });
throw redirect(303, '/admin/login');
};