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, pub username: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct PersonResponse { pub struct PersonResponse {
pub id: i32, pub id: i32,
@ -59,13 +59,13 @@ impl From<Person> for PersonResponse {
} }
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct PersonsResponse { pub struct PersonsResponse {
pub persons: Vec<PersonResponse>, pub persons: Vec<PersonResponse>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct PersonActionResponse { pub struct PersonActionResponse {
pub person: PersonResponse, 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/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"dotenv": "^16.4.5",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11", "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[]>; raw?: () => Record<string, string[]>;
}; };
const setCookies = typeof headerUtils.getSetCookie === 'function' const setCookies =
typeof headerUtils.getSetCookie === 'function'
? headerUtils.getSetCookie() ? headerUtils.getSetCookie()
: headerUtils.raw?.()['set-cookie'] ?? []; : (headerUtils.raw?.()['set-cookie'] ?? []);
return { response, setCookies }; return { response, setCookies };
} }

View file

@ -46,14 +46,24 @@
<div class="min-h-screen bg-slate-100 text-slate-900"> <div class="min-h-screen bg-slate-100 text-slate-900">
<header class="border-b border-slate-200 bg-white"> <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> <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> <h1 class="text-xl font-semibold text-slate-900">Gästhantering</h1>
</div> </div>
{#if ui.loggedIn} {#if ui.loggedIn}
<div class="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-4"> <button
<nav class="flex items-center gap-2"> 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 <a
href="/" href="/"
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${ class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
@ -105,14 +115,6 @@
Översikt Översikt
</a> </a>
</nav> </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} {/if}
</div> </div>
{#if ui.message} {#if ui.message}

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { Person } from '$lib/types'; import type { Person } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events';
let searchQuery = $state(''); let searchQuery = $state('');
let searchResults = $state<Person[]>([]); let searchResults = $state<Person[]>([]);
@ -139,19 +140,27 @@
onMount(() => { onMount(() => {
void loadDefaultList(); void loadDefaultList();
const stop = listenToPersonEvents((person) => {
updatePersonList(person);
});
return () => {
stop();
};
}); });
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <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> <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}> <form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
<input <input
type="text" type="text"
placeholder="Exempel: Anna, 42 eller 0701234567" placeholder="Exempel: Anna, 42 eller 0701234567"
bind:value={searchQuery} 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 <button
type="submit" type="submit"
@ -178,18 +187,27 @@
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4> <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>
<div class="flex flex-wrap gap-2"> <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
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${ 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 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span> }`}>{person.inside ? 'Inne' : 'Ute'}</span
>
</div> </div>
</div> </div>
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p> <p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
{#if person.under_ten} {#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. VARNING: Person under 10 år kompletterande information krävs innan incheckning.
</p> </p>
{/if} {/if}
@ -200,7 +218,7 @@
disabled={person.checked_in || searchLoading} disabled={person.checked_in || searchLoading}
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${ class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
person.checked_in 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' : '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.'); 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' method: 'GET'
}); }
);
const headers = new Headers(); const headers = new Headers();
for (const cookie of setCookies) { for (const cookie of setCookies) {

View file

@ -2,6 +2,8 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { Person } from '$lib/types'; import type { Person } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events';
import { updateCollection } from '$lib/client/person-collection';
type StatusFilter = 'all' | 'inside' | 'outside'; type StatusFilter = 'all' | 'inside' | 'outside';
type CheckedFilter = 'all' | 'checked-in' | 'not-checked-in'; type CheckedFilter = 'all' | 'checked-in' | 'not-checked-in';
@ -23,6 +25,47 @@
return response; 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() { async function fetchCheckedIn() {
loading = true; loading = true;
errorMessage = ''; errorMessage = '';
@ -61,20 +104,8 @@
} }
const data = await response.json(); const data = await response.json();
let list: Person[] = data.persons ?? []; const list: Person[] = data.persons ?? [];
applyFilteredList(list);
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.';
}
} catch (err) { } catch (err) {
console.error('Fetch checked-in failed', err); console.error('Fetch checked-in failed', err);
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.'; errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
@ -99,6 +130,12 @@
onMount(() => { onMount(() => {
void fetchCheckedIn(); void fetchCheckedIn();
const stop = listenToPersonEvents((person) => {
handlePersonUpdate(person);
});
return () => {
stop();
};
}); });
</script> </script>
@ -108,7 +145,8 @@
<div> <div>
<h2 class="text-lg font-semibold text-slate-800">Översikt</h2> <h2 class="text-lg font-semibold text-slate-800">Översikt</h2>
<p class="text-sm text-slate-500"> <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> </p>
</div> </div>
<div class="text-sm text-slate-500"> <div class="text-sm text-slate-500">
@ -128,7 +166,7 @@
id="checked-query" id="checked-query"
placeholder="Exempel: 42, Anna eller 0701234567" placeholder="Exempel: 42, Anna eller 0701234567"
bind:value={searchQuery} 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>
<div> <div>
@ -137,7 +175,7 @@
id="status" id="status"
bind:value={statusFilter} bind:value={statusFilter}
onchange={handleStatusChange} 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="all">Alla</option>
<option value="inside">Endast inne</option> <option value="inside">Endast inne</option>
@ -145,12 +183,14 @@
</select> </select>
</div> </div>
<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 <select
id="checked" id="checked"
bind:value={checkedFilter} bind:value={checkedFilter}
onchange={handleCheckedChange} 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="checked-in">Endast incheckade</option>
<option value="not-checked-in">Endast ej 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> <p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${ <span
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600' class={`rounded-full px-3 py-1 text-xs font-semibold ${
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span> person.checked_in
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${ ? '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 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span> }`}>{person.inside ? 'Inne' : 'Ute'}</span
>
</div> </div>
</header> </header>
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p> <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 { goto } from '$app/navigation';
import type { Person } from '$lib/types'; import type { Person } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events';
let searchQuery = $state(''); let searchQuery = $state('');
let searchResults = $state<Person[]>([]); let searchResults = $state<Person[]>([]);
@ -142,19 +143,27 @@
onMount(() => { onMount(() => {
void loadDefaultList(); void loadDefaultList();
const stop = listenToPersonEvents((person) => {
updatePersonList(person);
});
return () => {
stop();
};
}); });
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <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> <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}> <form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
<input <input
type="text" type="text"
placeholder="Exempel: Anna, 42 eller 0701234567" placeholder="Exempel: Anna, 42 eller 0701234567"
bind:value={searchQuery} 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 <button
type="submit" type="submit"
@ -181,20 +190,30 @@
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4> <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>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${ <span
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600' class={`rounded-full px-3 py-1 text-xs font-semibold ${
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span> person.checked_in
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${ ? '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 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span> }`}>{person.inside ? 'Inne' : 'Ute'}</span
>
</div> </div>
</div> </div>
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p> <p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
{#if person.under_ten} {#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. VARNING: Person under 10 år kompletterande information krävs innan utcheckning.
</p> </p>
{/if} {/if}
@ -206,7 +225,7 @@
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${ class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
person.checked_in person.checked_in
? 'bg-red-600 text-white hover:bg-red-700' ? '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 Checka ut

View file

@ -28,19 +28,21 @@
inside: false inside: false
}; };
const values = $derived({ const values = $derived({
...defaults, ...defaults,
...(props.form?.values ?? {}) ...(props.form?.values ?? {})
} as FormValues); } as FormValues);
const errors = $derived((props.form?.errors ?? {}) as FormErrors); const errors = $derived((props.form?.errors ?? {}) as FormErrors);
const success = $derived(props.form?.success ?? null); const success = $derived(props.form?.success ?? null);
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <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> <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"> <form method="POST" class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-slate-600" for="name">Namn</label> <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" name="name"
value={values.name} value={values.name}
required 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} {#if errors.name}
<p class="mt-1 text-sm text-red-600">{errors.name}</p> <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" min="0"
value={values.age} value={values.age}
required 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} {#if errors.age}
<p class="mt-1 text-sm text-red-600">{errors.age}</p> <p class="mt-1 text-sm text-red-600">{errors.age}</p>
{/if} {/if}
</div> </div>
<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 <input
type="tel" type="tel"
id="phone" id="phone"
name="phone_number" name="phone_number"
value={values.phone_number} value={values.phone_number}
required 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} {#if errors.phone_number}
<p class="mt-1 text-sm text-red-600">{errors.phone_number}</p> <p class="mt-1 text-sm text-red-600">{errors.phone_number}</p>
{/if} {/if}
</div> </div>
<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 <input
type="number" type="number"
id="manual-id" id="manual-id"
name="manual_id" name="manual_id"
min="0" min="0"
value={values.manual_id} 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} {#if errors.manual_id}
<p class="mt-1 text-sm text-red-600">{errors.manual_id}</p> <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> <label for="inside" class="text-sm text-slate-700">Markera som inne</label>
</div> </div>
{#if errors.general} {#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}
{#if success} {#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} {/if}
<div class="md:col-span-2 flex items-center gap-3"> <div class="flex items-center gap-3 md:col-span-2">
<button <button
type="submit" type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700" 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 { goto } from '$app/navigation';
import type { Person } from '$lib/types'; import type { Person } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events';
import { updateCollection } from '$lib/client/person-collection';
type StatusFilter = 'all' | 'inside' | 'outside'; type StatusFilter = 'all' | 'inside' | 'outside';
@ -22,6 +24,40 @@
return response; 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() { async function fetchPersons() {
loading = true; loading = true;
errorMessage = ''; errorMessage = '';
@ -59,19 +95,8 @@
} }
const data = await response.json(); const data = await response.json();
let list: Person[] = data.persons ?? []; const list: Person[] = data.persons ?? [];
applyFilteredList(list);
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.';
}
} catch (err) { } catch (err) {
console.error('Fetch inside status failed', err); console.error('Fetch inside status failed', err);
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.'; 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 }; const body = JSON.parse(text) as { person?: Person };
if (body.person) { if (body.person) {
const updated = body.person; const updated = body.person;
persons = persons.map((entry) => (entry.id === updated.id ? updated : entry)); handlePersonUpdate(updated);
actionMessage = updated.inside actionMessage = updated.inside
? `${updated.name} är nu markerad som inne.` ? `${updated.name} är nu markerad som inne.`
: `${updated.name} är nu markerad som ute.`; : `${updated.name} är nu markerad som ute.`;
@ -126,6 +151,12 @@
onMount(() => { onMount(() => {
void fetchPersons(); void fetchPersons();
const stop = listenToPersonEvents((person) => {
handlePersonUpdate(person);
});
return () => {
stop();
};
}); });
</script> </script>
@ -155,16 +186,18 @@
id="inside-query" id="inside-query"
placeholder="Exempel: 42, Anna eller 0701234567" placeholder="Exempel: 42, Anna eller 0701234567"
bind:value={searchQuery} 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>
<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 <select
id="inside-status" id="inside-status"
bind:value={statusFilter} bind:value={statusFilter}
onchange={handleStatusChange} 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="all">Alla</option>
<option value="inside">Endast inne</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> <p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
{/if} {/if}
{#if actionMessage && !errorMessage} {#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} {/if}
</section> </section>
@ -202,12 +237,18 @@
<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>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${ <span
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600' class={`rounded-full px-3 py-1 text-xs font-semibold ${
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span> person.checked_in
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${ ? '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 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span> }`}>{person.inside ? 'Inne' : 'Ute'}</span
>
</div> </div>
</header> </header>
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p> <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"> <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> <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"> <div class="space-y-1">
<label class="text-sm font-medium text-slate-600" for="username">Användarnamn</label> <label class="text-sm font-medium text-slate-600" for="username">Användarnamn</label>
<input <input
@ -51,7 +54,7 @@
bind:value={username} bind:value={username}
autocomplete="username" autocomplete="username"
required 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>
<div class="space-y-1"> <div class="space-y-1">
@ -63,7 +66,7 @@
bind:value={password} bind:value={password}
autocomplete="current-password" autocomplete="current-password"
required 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>
{#if errorMessage} {#if errorMessage}