added channels for automatic updates on every device and some security settings for hosting

This commit is contained in:
Sebastian 2025-09-20 17:15:41 +02:00
parent 19df7c8962
commit 464af45107
18 changed files with 516 additions and 207 deletions

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# VBytes Attendance
## Requirements
- Docker Engine (with Compose v2)
## Setup
1. Copy the provided `.env` file (already checked in). Adjust if needed:
```env
POSTGRES_PASSWORD=postgrespass123
JWT_SECRET=supersecretjwtkey
ADMIN_USERNAME=admin
ADMIN_PASSWORD=AdminPass!234
JWT_COOKIE_SECURE=false
ENABLE_HTTPS_REDIRECT=false
WEB_PORT=3000
CSRF_ALLOWED_ORIGINS=http://192.168.68.61:3000
```
- Change `JWT_SECRET` and `ADMIN_PASSWORD` before production use.
- Keep `JWT_COOKIE_SECURE=false` and `ENABLE_HTTPS_REDIRECT=false` unless you run behind HTTPS.
- Update `CSRF_ALLOWED_ORIGINS` to the host/port youll use to access the web app.
2. Start the stack:
```bash
docker compose up -d --build
```
3. Open the web app at `http://<this-machine-ip>:3000` using the admin credentials (`ADMIN_USERNAME`, `ADMIN_PASSWORD`).
4. Stop the stack:
```bash
docker compose down
```
To wipe Postgres data (e.g., after upgrading versions), also remove the volume:
```bash
docker volume rm vbytes_postgres-data
```
Thats it—the Compose file starts Postgres, the Rust API, and the SvelteKit frontend using the values from `.env`.

21
api/Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM rustlang/rust:nightly as builder
WORKDIR /app
# Cache dependencies
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
# Build application
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim as runtime
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/api /usr/local/bin/api
ENV ROCKET_ADDRESS=0.0.0.0
ENV ROCKET_PORT=8080
EXPOSE 8080
CMD ["api"]

View file

@ -32,7 +32,7 @@ pub struct LoginResponse {
pub username: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct PersonResponse {
pub id: i32,
@ -59,13 +59,13 @@ impl From<Person> for PersonResponse {
}
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct PersonsResponse {
pub persons: Vec<PersonResponse>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct PersonActionResponse {
pub person: PersonResponse,

5
package.json Normal file
View file

@ -0,0 +1,5 @@
{
"devDependencies": {
"dotenv": "^17.2.2"
}
}

View file

@ -18,6 +18,7 @@
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0",
"dotenv": "^16.4.5",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",

View file

@ -0,0 +1,29 @@
import type { Person } from '$lib/types';
type IncludeFn = (person: Person) => boolean;
export function updateCollection(
current: Person[],
updated: Person,
shouldInclude: IncludeFn
): Person[] {
const include = shouldInclude(updated);
const index = current.findIndex((person) => person.id === updated.id);
if (include) {
if (index >= 0) {
const next = current.slice();
next[index] = updated;
return next;
}
return [updated, ...current];
}
if (index >= 0) {
const next = current.slice();
next.splice(index, 1);
return next;
}
return current;
}

View file

@ -0,0 +1,40 @@
import type { Person } from '$lib/types';
export type PersonEvent = {
person: Person;
};
export function listenToPersonEvents(onPerson: (person: Person) => void) {
let stopped = false;
let source: EventSource | null = null;
function connect() {
if (stopped) return;
source = new EventSource('/api/events');
source.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as PersonEvent;
if (data.person) {
onPerson(data.person);
}
} catch (err) {
console.error('Failed to parse person event', err);
}
};
source.onerror = () => {
source?.close();
source = null;
if (stopped) return;
setTimeout(connect, 2000);
};
}
connect();
return () => {
stopped = true;
source?.close();
source = null;
};
}

View file

@ -30,9 +30,10 @@ export async function proxyRequest(
raw?: () => Record<string, string[]>;
};
const setCookies = typeof headerUtils.getSetCookie === 'function'
const setCookies =
typeof headerUtils.getSetCookie === 'function'
? headerUtils.getSetCookie()
: headerUtils.raw?.()['set-cookie'] ?? [];
: (headerUtils.raw?.()['set-cookie'] ?? []);
return { response, setCookies };
}

View file

@ -46,14 +46,24 @@
<div class="min-h-screen bg-slate-100 text-slate-900">
<header class="border-b border-slate-200 bg-white">
<div class="mx-auto flex max-w-5xl flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div class="mx-auto flex max-w-5xl flex-col gap-4 px-4 py-4">
<div class="flex items-start justify-between gap-3 sm:items-center">
<div>
<p class="text-sm font-medium uppercase tracking-wide text-slate-500">VBytes</p>
<p class="text-sm font-medium tracking-wide text-slate-500 uppercase">VBytes</p>
<h1 class="text-xl font-semibold text-slate-900">Gästhantering</h1>
</div>
{#if ui.loggedIn}
<div class="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-4">
<nav class="flex items-center gap-2">
<button
onclick={handleLogout}
disabled={ui.loggingOut}
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
>
{ui.loggingOut ? 'Loggar ut…' : 'Logga ut'}
</button>
{/if}
</div>
{#if ui.loggedIn}
<nav class="flex flex-wrap items-center justify-center gap-2 sm:justify-between">
<a
href="/"
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
@ -105,14 +115,6 @@
Översikt
</a>
</nav>
<button
onclick={handleLogout}
disabled={ui.loggingOut}
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
>
{ui.loggingOut ? 'Loggar ut…' : 'Logga ut'}
</button>
</div>
{/if}
</div>
{#if ui.message}

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import type { Person } from '$lib/types';
import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events';
let searchQuery = $state('');
let searchResults = $state<Person[]>([]);
@ -139,19 +140,27 @@
onMount(() => {
void loadDefaultList();
const stop = listenToPersonEvents((person) => {
updatePersonList(person);
});
return () => {
stop();
};
});
</script>
<div class="space-y-6">
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h3 class="text-lg font-semibold text-slate-800">Checka in</h3>
<p class="mb-4 text-sm text-slate-500">Listan visar automatiskt alla som inte är incheckade. Sök för att begränsa listan.</p>
<p class="mb-4 text-sm text-slate-500">
Listan visar automatiskt alla som inte är incheckade. Sök för att begränsa listan.
</p>
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
<input
type="text"
placeholder="Exempel: Anna, 42 eller 0701234567"
bind:value={searchQuery}
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
<button
type="submit"
@ -178,18 +187,27 @@
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4>
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
<p class="text-sm text-slate-500">
ID: {person.id} · Telefon: {person.phone_number}
</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Inte incheckad</span>
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
<span
class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600"
>Inte incheckad</span
>
<span
class={`rounded-full px-3 py-1 text-xs font-semibold ${
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
}`}>{person.inside ? 'Inne' : 'Ute'}</span
>
</div>
</div>
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
{#if person.under_ten}
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
<p
class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800"
>
VARNING: Person under 10 år kompletterande information krävs innan incheckning.
</p>
{/if}
@ -200,7 +218,7 @@
disabled={person.checked_in || searchLoading}
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
person.checked_in
? 'bg-slate-200 text-slate-600 cursor-not-allowed'
? 'cursor-not-allowed bg-slate-200 text-slate-600'
: 'bg-emerald-600 text-white hover:bg-emerald-700'
}`}
>

View file

@ -0,0 +1,31 @@
import type { RequestHandler } from './$types';
import { API_BASE_URL } from '$lib/server/config';
export const GET: RequestHandler = async (event) => {
const url = new URL('/events', API_BASE_URL).toString();
const headers = new Headers();
const cookie = event.request.headers.get('cookie');
if (cookie) {
headers.set('cookie', cookie);
}
headers.set('accept', 'text/event-stream');
const response = await event.fetch(url, {
method: 'GET',
headers,
credentials: 'include'
});
const proxiedHeaders = new Headers(response.headers);
if (!proxiedHeaders.has('content-type')) {
proxiedHeaders.set('content-type', 'text/event-stream');
}
proxiedHeaders.set('cache-control', 'no-cache');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: proxiedHeaders
});
};

View file

@ -8,9 +8,13 @@ export const GET: RequestHandler = async (event) => {
throw error(400, 'Söktext krävs.');
}
const { response, setCookies } = await proxyRequest(event, `/persons/search?q=${encodeURIComponent(query)}`, {
const { response, setCookies } = await proxyRequest(
event,
`/persons/search?q=${encodeURIComponent(query)}`,
{
method: 'GET'
});
}
);
const headers = new Headers();
for (const cookie of setCookies) {

View file

@ -2,6 +2,8 @@
import { goto } from '$app/navigation';
import type { Person } from '$lib/types';
import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events';
import { updateCollection } from '$lib/client/person-collection';
type StatusFilter = 'all' | 'inside' | 'outside';
type CheckedFilter = 'all' | 'checked-in' | 'not-checked-in';
@ -23,6 +25,47 @@
return response;
}
function matchesFilters(person: Person) {
if (checkedFilter === 'checked-in' && !person.checked_in) return false;
if (checkedFilter === 'not-checked-in' && person.checked_in) return false;
if (statusFilter === 'inside' && !person.inside) return false;
if (statusFilter === 'outside' && person.inside) return false;
if (searchQuery.trim()) {
const query = searchQuery.trim().toLowerCase();
const matchesText =
person.name.toLowerCase().includes(query) ||
person.phone_number.toLowerCase().includes(query) ||
person.id.toString() === query;
if (!matchesText) return false;
}
return true;
}
function applyFilteredList(list: Person[]) {
const filtered = list.filter((person) => {
if (statusFilter === 'inside' && !person.inside) return false;
if (statusFilter === 'outside' && person.inside) return false;
if (checkedFilter === 'checked-in' && !person.checked_in) return false;
if (checkedFilter === 'not-checked-in' && person.checked_in) return false;
return true;
});
persons = filtered;
if (persons.length === 0) {
infoMessage = 'Inga personer matchar kriterierna.';
} else {
infoMessage = '';
}
}
function handlePersonUpdate(person: Person) {
persons = updateCollection(persons, person, matchesFilters);
if (persons.length === 0) {
infoMessage = 'Inga personer matchar kriterierna.';
} else {
infoMessage = '';
}
}
async function fetchCheckedIn() {
loading = true;
errorMessage = '';
@ -61,20 +104,8 @@
}
const data = await response.json();
let list: Person[] = data.persons ?? [];
list = list.filter((person) => {
if (checkedFilter === 'checked-in' && !person.checked_in) return false;
if (checkedFilter === 'not-checked-in' && person.checked_in) return false;
if (statusFilter === 'inside' && !person.inside) return false;
if (statusFilter === 'outside' && person.inside) return false;
return true;
});
persons = list;
if (persons.length === 0) {
infoMessage = 'Inga personer matchar kriterierna.';
}
const list: Person[] = data.persons ?? [];
applyFilteredList(list);
} catch (err) {
console.error('Fetch checked-in failed', err);
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
@ -99,6 +130,12 @@
onMount(() => {
void fetchCheckedIn();
const stop = listenToPersonEvents((person) => {
handlePersonUpdate(person);
});
return () => {
stop();
};
});
</script>
@ -108,7 +145,8 @@
<div>
<h2 class="text-lg font-semibold text-slate-800">Översikt</h2>
<p class="text-sm text-slate-500">
Visar personer med status, både incheckade och inte incheckade. Sök efter namn, telefonnummer eller id och filtrera på inne/ute.
Visar personer med status, både incheckade och inte incheckade. Sök efter namn,
telefonnummer eller id och filtrera på inne/ute.
</p>
</div>
<div class="text-sm text-slate-500">
@ -128,7 +166,7 @@
id="checked-query"
placeholder="Exempel: 42, Anna eller 0701234567"
bind:value={searchQuery}
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
</div>
<div>
@ -137,7 +175,7 @@
id="status"
bind:value={statusFilter}
onchange={handleStatusChange}
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
>
<option value="all">Alla</option>
<option value="inside">Endast inne</option>
@ -145,12 +183,14 @@
</select>
</div>
<div>
<label for="checked" class="mb-1 block text-sm font-medium text-slate-600">Incheckning</label>
<label for="checked" class="mb-1 block text-sm font-medium text-slate-600"
>Incheckning</label
>
<select
id="checked"
bind:value={checkedFilter}
onchange={handleCheckedChange}
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
>
<option value="checked-in">Endast incheckade</option>
<option value="not-checked-in">Endast ej incheckade</option>
@ -185,12 +225,18 @@
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
</div>
<div class="flex flex-wrap gap-2">
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span>
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
<span
class={`rounded-full px-3 py-1 text-xs font-semibold ${
person.checked_in
? 'bg-emerald-100 text-emerald-700'
: 'bg-slate-100 text-slate-600'
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span
>
<span
class={`rounded-full px-3 py-1 text-xs font-semibold ${
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
}`}>{person.inside ? 'Inne' : 'Ute'}</span
>
</div>
</header>
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import type { Person } from '$lib/types';
import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events';
let searchQuery = $state('');
let searchResults = $state<Person[]>([]);
@ -142,19 +143,27 @@
onMount(() => {
void loadDefaultList();
const stop = listenToPersonEvents((person) => {
updatePersonList(person);
});
return () => {
stop();
};
});
</script>
<div class="space-y-6">
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h3 class="text-lg font-semibold text-slate-800">Checka ut</h3>
<p class="mb-4 text-sm text-slate-500">Sök på namn, id eller telefonnummer för att checka ut personer som är incheckade.</p>
<p class="mb-4 text-sm text-slate-500">
Sök på namn, id eller telefonnummer för att checka ut personer som är incheckade.
</p>
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
<input
type="text"
placeholder="Exempel: Anna, 42 eller 0701234567"
bind:value={searchQuery}
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
<button
type="submit"
@ -181,20 +190,30 @@
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4>
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
<p class="text-sm text-slate-500">
ID: {person.id} · Telefon: {person.phone_number}
</p>
</div>
<div class="flex flex-wrap gap-2">
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span>
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
<span
class={`rounded-full px-3 py-1 text-xs font-semibold ${
person.checked_in
? 'bg-emerald-100 text-emerald-700'
: 'bg-slate-100 text-slate-600'
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span
>
<span
class={`rounded-full px-3 py-1 text-xs font-semibold ${
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
}`}>{person.inside ? 'Inne' : 'Ute'}</span
>
</div>
</div>
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
{#if person.under_ten}
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
<p
class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800"
>
VARNING: Person under 10 år kompletterande information krävs innan utcheckning.
</p>
{/if}
@ -206,7 +225,7 @@
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
person.checked_in
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-slate-200 text-slate-600 cursor-not-allowed'
: 'cursor-not-allowed bg-slate-200 text-slate-600'
}`}
>
Checka ut

View file

@ -40,7 +40,9 @@ const success = $derived(props.form?.success ?? null);
<div class="space-y-6">
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-800">Lägg till person</h2>
<p class="mb-4 text-sm text-slate-500">Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt.</p>
<p class="mb-4 text-sm text-slate-500">
Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt.
</p>
<form method="POST" class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-slate-600" for="name">Namn</label>
@ -50,7 +52,7 @@ const success = $derived(props.form?.success ?? null);
name="name"
value={values.name}
required
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
{#if errors.name}
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
@ -65,35 +67,39 @@ const success = $derived(props.form?.success ?? null);
min="0"
value={values.age}
required
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
{#if errors.age}
<p class="mt-1 text-sm text-red-600">{errors.age}</p>
{/if}
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-600" for="phone">Telefonnummer</label>
<label class="mb-1 block text-sm font-medium text-slate-600" for="phone"
>Telefonnummer</label
>
<input
type="tel"
id="phone"
name="phone_number"
value={values.phone_number}
required
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
{#if errors.phone_number}
<p class="mt-1 text-sm text-red-600">{errors.phone_number}</p>
{/if}
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-600" for="manual-id">ID (valfritt)</label>
<label class="mb-1 block text-sm font-medium text-slate-600" for="manual-id"
>ID (valfritt)</label
>
<input
type="number"
id="manual-id"
name="manual_id"
min="0"
value={values.manual_id}
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
{#if errors.manual_id}
<p class="mt-1 text-sm text-red-600">{errors.manual_id}</p>
@ -122,12 +128,16 @@ const success = $derived(props.form?.success ?? null);
<label for="inside" class="text-sm text-slate-700">Markera som inne</label>
</div>
{#if errors.general}
<p class="md:col-span-2 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errors.general}</p>
<p class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600 md:col-span-2">
{errors.general}
</p>
{/if}
{#if success}
<p class="md:col-span-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{success}</p>
<p class="rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700 md:col-span-2">
{success}
</p>
{/if}
<div class="md:col-span-2 flex items-center gap-3">
<div class="flex items-center gap-3 md:col-span-2">
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700"

View file

@ -2,6 +2,8 @@
import { goto } from '$app/navigation';
import type { Person } from '$lib/types';
import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events';
import { updateCollection } from '$lib/client/person-collection';
type StatusFilter = 'all' | 'inside' | 'outside';
@ -22,6 +24,40 @@
return response;
}
function matchesFilters(person: Person) {
if (!person.checked_in) return false;
if (statusFilter === 'inside' && !person.inside) return false;
if (statusFilter === 'outside' && person.inside) return false;
const query = searchQuery.trim().toLowerCase();
if (query) {
const matchesText =
person.name.toLowerCase().includes(query) ||
person.phone_number.toLowerCase().includes(query) ||
person.id.toString() === query;
if (!matchesText) return false;
}
return true;
}
function applyFilteredList(list: Person[]) {
const filtered = list.filter(matchesFilters);
persons = filtered;
if (persons.length === 0) {
infoMessage = 'Inga incheckade personer matchar kriterierna.';
} else {
infoMessage = '';
}
}
function handlePersonUpdate(person: Person) {
persons = updateCollection(persons, person, matchesFilters);
if (persons.length === 0) {
infoMessage = 'Inga incheckade personer matchar kriterierna.';
} else {
infoMessage = '';
}
}
async function fetchPersons() {
loading = true;
errorMessage = '';
@ -59,19 +95,8 @@
}
const data = await response.json();
let list: Person[] = data.persons ?? [];
list = list.filter((person) => {
if (!person.checked_in) return false;
if (statusFilter === 'inside' && !person.inside) return false;
if (statusFilter === 'outside' && person.inside) return false;
return true;
});
persons = list;
if (persons.length === 0) {
infoMessage = 'Inga incheckade personer matchar kriterierna.';
}
const list: Person[] = data.persons ?? [];
applyFilteredList(list);
} catch (err) {
console.error('Fetch inside status failed', err);
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
@ -113,7 +138,7 @@
const body = JSON.parse(text) as { person?: Person };
if (body.person) {
const updated = body.person;
persons = persons.map((entry) => (entry.id === updated.id ? updated : entry));
handlePersonUpdate(updated);
actionMessage = updated.inside
? `${updated.name} är nu markerad som inne.`
: `${updated.name} är nu markerad som ute.`;
@ -126,6 +151,12 @@
onMount(() => {
void fetchPersons();
const stop = listenToPersonEvents((person) => {
handlePersonUpdate(person);
});
return () => {
stop();
};
});
</script>
@ -155,16 +186,18 @@
id="inside-query"
placeholder="Exempel: 42, Anna eller 0701234567"
bind:value={searchQuery}
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
</div>
<div>
<label for="inside-status" class="mb-1 block text-sm font-medium text-slate-600">Status</label>
<label for="inside-status" class="mb-1 block text-sm font-medium text-slate-600"
>Status</label
>
<select
id="inside-status"
bind:value={statusFilter}
onchange={handleStatusChange}
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
>
<option value="all">Alla</option>
<option value="inside">Endast inne</option>
@ -189,7 +222,9 @@
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
{/if}
{#if actionMessage && !errorMessage}
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionMessage}</p>
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
{actionMessage}
</p>
{/if}
</section>
@ -202,12 +237,18 @@
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
</div>
<div class="flex flex-wrap gap-2">
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span>
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
<span
class={`rounded-full px-3 py-1 text-xs font-semibold ${
person.checked_in
? 'bg-emerald-100 text-emerald-700'
: 'bg-slate-100 text-slate-600'
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span
>
<span
class={`rounded-full px-3 py-1 text-xs font-semibold ${
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
}`}>{person.inside ? 'Inne' : 'Ute'}</span
>
</div>
</header>
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>

View file

@ -41,7 +41,10 @@
<div class="mx-auto flex min-h-[60vh] max-w-md flex-col justify-center gap-6">
<h2 class="text-center text-2xl font-semibold text-slate-800">Logga in</h2>
<form class="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm" onsubmit={handleSubmit}>
<form
class="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm"
onsubmit={handleSubmit}
>
<div class="space-y-1">
<label class="text-sm font-medium text-slate-600" for="username">Användarnamn</label>
<input
@ -51,7 +54,7 @@
bind:value={username}
autocomplete="username"
required
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
</div>
<div class="space-y-1">
@ -63,7 +66,7 @@
bind:value={password}
autocomplete="current-password"
required
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
</div>
{#if errorMessage}