Admin-Bereich, PWA-Manifest und Deploy-Setup

- Admin: Login, Dashboard, Protokoll, Stationen mit QR-Links
- PWA: vite-plugin-pwa mit Workbox Offline-Caching
- SvelteKit adapter-node + Dockerfile für DS-Deployment
- docker-compose.yml mit app + pocketbase Services
- Makefile: make deploy Befehl
This commit is contained in:
rene 2026-05-17 11:37:42 +02:00
parent f2615c9e07
commit 18570a42f0
15 changed files with 6042 additions and 12 deletions

View file

@ -8,9 +8,18 @@ DS_IP := 10.47.11.10
DS_SSH_PORT := 4711 DS_SSH_PORT := 4711
DS_PATH := /volume1/docker/checkflo DS_PATH := /volume1/docker/checkflo
CONTAINER_PB := checkflo-pocketbase CONTAINER_PB := checkflo-pocketbase
CONTAINER_APP := checkflo-app
DOCKER := sudo /usr/local/bin/docker DOCKER := sudo /usr/local/bin/docker
.PHONY: help check-ssh start stop restart status logs logs-f shell-pb pb-admin TAR_EXCLUDE := --exclude='.git' \
--exclude='./app/node_modules' \
--exclude='./app/.svelte-kit' \
--exclude='./app/build' \
--exclude='./.env' \
--exclude='./.DS_Store'
.PHONY: help check-ssh start stop restart status logs logs-f logs-app \
shell-pb pb-admin deploy setup-db seed-demo
# ---------------------------------------------------------- # ----------------------------------------------------------
# Hilfe # Hilfe
@ -19,13 +28,15 @@ help:
@echo "" @echo ""
@echo " Checkflo — verfügbare Befehle:" @echo " Checkflo — verfügbare Befehle:"
@echo "" @echo ""
@echo " make start PocketBase starten" @echo " make deploy App bauen + zur DS übertragen + Container neu starten"
@echo " make stop PocketBase stoppen" @echo " make start Alle Container starten"
@echo " make restart PocketBase neu starten" @echo " make stop Alle Container stoppen"
@echo " make restart Alle Container neu starten"
@echo " make status Container-Status anzeigen" @echo " make status Container-Status anzeigen"
@echo "" @echo ""
@echo " make logs Letzte 100 Zeilen" @echo " make logs PocketBase-Logs (100 Zeilen)"
@echo " make logs-f Live-Log-Stream" @echo " make logs-app App-Logs (100 Zeilen)"
@echo " make logs-f PocketBase Live-Log"
@echo " make shell-pb Shell in PocketBase-Container" @echo " make shell-pb Shell in PocketBase-Container"
@echo " make pb-admin PocketBase Admin-URL anzeigen" @echo " make pb-admin PocketBase Admin-URL anzeigen"
@echo "" @echo ""
@ -41,12 +52,27 @@ check-ssh:
exit 1; \ exit 1; \
fi fi
# ----------------------------------------------------------
# DEPLOY — Dateien zur DS + Docker rebuild
# ----------------------------------------------------------
deploy: check-ssh
@echo "→ Sync zu DS..."
@COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(DS_PATH)/"
@echo "→ Docker rebuild + restart..."
@ssh $(DS_HOST) " \
cd $(DS_PATH) && \
$(DOCKER) compose down && \
$(DOCKER) compose build app && \
$(DOCKER) compose up -d"
@echo " ✓ Deploy fertig."
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=10"
# ---------------------------------------------------------- # ----------------------------------------------------------
# START # START
# ---------------------------------------------------------- # ----------------------------------------------------------
start: check-ssh start: check-ssh
@ssh $(DS_HOST) "cd $(DS_PATH) && $(DOCKER) compose up -d" @ssh $(DS_HOST) "cd $(DS_PATH) && $(DOCKER) compose up -d"
@echo " ✓ PocketBase gestartet." @echo " ✓ Gestartet."
# ---------------------------------------------------------- # ----------------------------------------------------------
# STOP # STOP
@ -67,7 +93,7 @@ restart: check-ssh
# ---------------------------------------------------------- # ----------------------------------------------------------
status: check-ssh status: check-ssh
@ssh $(DS_HOST) "$(DOCKER) ps \ @ssh $(DS_HOST) "$(DOCKER) ps \
--filter name=$(CONTAINER_PB) \ --filter name=checkflo \
--format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
# ---------------------------------------------------------- # ----------------------------------------------------------
@ -76,6 +102,9 @@ status: check-ssh
logs: check-ssh logs: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) --tail=100" @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) --tail=100"
logs-app: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=100"
logs-f: check-ssh logs-f: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) -f" @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) -f"

15
app/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
EXPOSE 3000
ENV HOST=0.0.0.0 PORT=3000
CMD ["node", "build"]

5250
app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,12 +13,14 @@
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"svelte": "^5.55.2", "svelte": "^5.55.2",
"svelte-check": "^4.4.6", "svelte-check": "^4.4.6",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^8.0.7" "vite": "^8.0.7",
"vite-plugin-pwa": "^1.3.0"
}, },
"dependencies": { "dependencies": {
"pocketbase": "^0.26.9" "pocketbase": "^0.26.9"

View file

@ -4,6 +4,9 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" /> <meta name="text-scale" content="scale" />
<meta name="theme-color" content="#0B1023">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View file

@ -0,0 +1,122 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { pb } from '$lib/pb';
import logo from '$lib/assets/checkflo-logo.png';
let { children } = $props();
const isLoginPage = $derived($page.url.pathname === '/admin/login');
function logout() {
pb.authStore.clear();
goto('/admin/login');
}
const navItems = [
{ href: '/admin/dashboard', label: 'Dashboard', icon: '◈' },
{ href: '/admin/logs', label: 'Protokoll', icon: '≡' },
{ href: '/admin/stations', label: 'Stationen', icon: '⊞' }
];
</script>
{#if isLoginPage}
{@render children()}
{:else}
<div class="shell">
<aside>
<a href="/admin/dashboard" class="logo-link">
<img src={logo} alt="checkflo" height="30" />
</a>
<nav>
{#each navItems as item}
<a
href={item.href}
class="nav-item"
class:active={$page.url.pathname.startsWith(item.href)}
>
<span class="nav-icon">{item.icon}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<button class="logout" onclick={logout}>Abmelden</button>
</aside>
<main>
{@render children()}
</main>
</div>
{/if}
<style>
.shell {
display: flex;
min-height: 100dvh;
background: #F5F7FA;
}
aside {
width: 220px;
background: #0B1023;
color: #fff;
display: flex;
flex-direction: column;
padding: 1.5rem 1rem;
flex-shrink: 0;
}
.logo-link { display: block; margin-bottom: 2rem; padding: 0 0.5rem; }
.logo-link img { filter: brightness(0) invert(1); }
nav { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; }
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.75rem;
border-radius: 8px;
color: #8892a4;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.15s;
}
.nav-item:hover { background: rgba(255,255,255,0.06); color: #fff; }
.nav-item.active { background: rgba(249,115,22,0.15); color: #F97316; }
.nav-icon { font-size: 1.1rem; }
.logout {
background: none;
border: 1px solid #2a3347;
color: #8892a4;
padding: 0.6rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.15s;
margin-top: 1rem;
}
.logout:hover { border-color: #F97316; color: #F97316; }
main {
flex: 1;
overflow: auto;
padding: 2rem;
}
@media (max-width: 640px) {
.shell { flex-direction: column; }
aside {
width: 100%;
flex-direction: row;
align-items: center;
padding: 0.75rem 1rem;
}
.logo-link { margin-bottom: 0; margin-right: auto; }
nav { flex-direction: row; flex: 0; gap: 0.5rem; }
.nav-item span:last-child { display: none; }
.logout { margin-top: 0; padding: 0.5rem 0.75rem; }
main { padding: 1rem; }
}
</style>

View file

@ -0,0 +1,14 @@
import { redirect } from '@sveltejs/kit';
import { pb } from '$lib/pb';
export const ssr = false;
export function load({ url }) {
const isLoginPage = url.pathname === '/admin/login';
if (!pb.authStore.isValid && !isLoginPage) {
throw redirect(303, '/admin/login');
}
if (pb.authStore.isValid && isLoginPage) {
throw redirect(303, '/admin/dashboard');
}
}

View file

@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
export const ssr = false;
export function load() {
throw redirect(303, '/admin/dashboard');
}

View file

@ -0,0 +1,148 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { onMount } from 'svelte';
const STATUS_LABEL: Record<string, string> = {
ok: 'OK', abweichung: 'Abweichung', kritisch: 'Kritisch'
};
const STATUS_COLOR: Record<string, string> = {
ok: '#16a34a', abweichung: '#ea580c', kritisch: '#dc2626'
};
let logs = $state<any[]>([]);
let stats = $state({ total: 0, ok: 0, abweichung: 0, kritisch: 0 });
let loading = $state(true);
onMount(async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tenantId = (pb.authStore.record as any)?.tenant;
const result = await pb.collection('check_logs').getList(1, 50, {
filter: `tenant = "${tenantId}" && created >= "${today.toISOString()}"`,
expand: 'station',
sort: '-created'
});
logs = result.items;
stats.total = result.totalItems;
stats.ok = logs.filter(l => l.status === 'ok').length;
stats.abweichung = logs.filter(l => l.status === 'abweichung').length;
stats.kritisch = logs.filter(l => l.status === 'kritisch').length;
loading = false;
});
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
</script>
<svelte:head><title>Dashboard — checkflo</title></svelte:head>
<div class="page">
<h1>Dashboard</h1>
<p class="date">{new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })}</p>
<div class="stats">
<div class="stat">
<span class="stat-num">{stats.total}</span>
<span class="stat-label">Checks heute</span>
</div>
<div class="stat stat-ok">
<span class="stat-num">{stats.ok}</span>
<span class="stat-label">In Ordnung</span>
</div>
<div class="stat stat-warn">
<span class="stat-num">{stats.abweichung}</span>
<span class="stat-label">Abweichungen</span>
</div>
<div class="stat stat-crit">
<span class="stat-num">{stats.kritisch}</span>
<span class="stat-label">Kritisch</span>
</div>
</div>
<h2>Heutige Einträge</h2>
{#if loading}
<p class="hint">Lädt…</p>
{:else if logs.length === 0}
<p class="hint">Noch keine Einträge heute.</p>
{:else}
<div class="log-list">
{#each logs as log}
<div class="log-item">
<div class="log-status" style="background: {STATUS_COLOR[log.status]}20; color: {STATUS_COLOR[log.status]}">
{STATUS_LABEL[log.status]}
</div>
<div class="log-info">
<span class="log-station">{log.expand?.station?.name ?? '—'}</span>
{#if log.temperature}
<span class="log-temp">{log.temperature} °C</span>
{/if}
</div>
<div class="log-meta">
<span>{log.checked_by}</span>
<span class="log-time">{formatTime(log.created)}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.page { max-width: 800px; }
h1 { font-size: 1.6rem; font-weight: 800; color: #0B1023; }
h2 { font-size: 1.1rem; font-weight: 700; color: #0B1023; margin: 2rem 0 1rem; }
.date { color: #888; margin-top: 0.25rem; margin-bottom: 2rem; }
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.stat {
background: #fff;
border-radius: 12px;
padding: 1.25rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
border-left: 3px solid #e2e8f0;
}
.stat-ok { border-color: #16a34a; }
.stat-warn { border-color: #ea580c; }
.stat-crit { border-color: #dc2626; }
.stat-num { display: block; font-size: 2rem; font-weight: 800; color: #0B1023; line-height: 1; }
.stat-label { font-size: 0.8rem; color: #888; margin-top: 0.25rem; display: block; }
.hint { color: #aaa; font-size: 0.95rem; }
.log-list { display: flex; flex-direction: column; gap: 0.6rem; }
.log-item {
background: #fff;
border-radius: 10px;
padding: 0.9rem 1.1rem;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
}
.log-status {
padding: 0.25rem 0.7rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 700;
white-space: nowrap;
}
.log-info { flex: 1; display: flex; flex-direction: column; gap: 0.1rem; }
.log-station { font-weight: 600; font-size: 0.95rem; }
.log-temp { font-size: 0.85rem; color: #555; }
.log-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 0.1rem; font-size: 0.85rem; color: #888; }
.log-time { font-size: 0.8rem; }
@media (max-width: 600px) {
.stats { grid-template-columns: repeat(2, 1fr); }
}
</style>

View file

@ -0,0 +1,105 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pb } from '$lib/pb';
import logo from '$lib/assets/checkflo-logo.png';
let email = $state('');
let password = $state('');
let loading = $state(false);
let error = $state('');
async function login() {
if (!email || !password) { error = 'Bitte alle Felder ausfüllen.'; return; }
loading = true;
error = '';
try {
await pb.collection('users').authWithPassword(email, password);
goto('/admin/dashboard');
} catch {
error = 'E-Mail oder Passwort falsch.';
} finally {
loading = false;
}
}
</script>
<svelte:head><title>Anmelden — checkflo</title></svelte:head>
<div class="page">
<div class="card">
<img src={logo} alt="checkflo" class="logo" />
<h1>Anmelden</h1>
<form onsubmit={(e) => { e.preventDefault(); login(); }}>
<div class="field">
<label for="email">E-Mail</label>
<input id="email" type="email" autocomplete="email" bind:value={email} />
</div>
<div class="field">
<label for="pw">Passwort</label>
<input id="pw" type="password" autocomplete="current-password" bind:value={password} />
</div>
{#if error}<p class="error">{error}</p>{/if}
<button type="submit" disabled={loading}>
{loading ? 'Anmelden…' : 'Anmelden'}
</button>
</form>
</div>
</div>
<style>
.page {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
background: #F5F7FA;
padding: 1rem;
}
.card {
background: #fff;
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 380px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
}
.logo { height: 32px; margin-bottom: 1.5rem; }
h1 { font-size: 1.4rem; font-weight: 800; color: #0B1023; margin-bottom: 1.5rem; }
.field { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 1rem; }
label { font-size: 0.85rem; font-weight: 600; color: #444; }
input {
border: 1.5px solid #ddd;
border-radius: 8px;
padding: 0.7rem 0.9rem;
font-size: 1rem;
transition: border-color 0.15s;
}
input:focus { outline: none; border-color: #F97316; }
.error {
background: #fef2f2;
color: #dc2626;
padding: 0.6rem 0.9rem;
border-radius: 8px;
font-size: 0.9rem;
margin-bottom: 1rem;
}
button {
width: 100%;
background: #F97316;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.85rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
margin-top: 0.5rem;
transition: background 0.15s;
}
button:hover { background: #ea6c10; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
</style>

View file

@ -0,0 +1,129 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { onMount } from 'svelte';
const STATUS_LABEL: Record<string, string> = {
ok: 'OK', abweichung: 'Abweichung', kritisch: 'Kritisch'
};
const STATUS_COLOR: Record<string, string> = {
ok: '#16a34a', abweichung: '#ea580c', kritisch: '#dc2626'
};
let logs = $state<any[]>([]);
let loading = $state(true);
let page = $state(1);
let totalPages = $state(1);
const PER_PAGE = 30;
onMount(() => loadPage(1));
async function loadPage(p: number) {
loading = true;
const tenantId = (pb.authStore.record as any)?.tenant;
const result = await pb.collection('check_logs').getList(p, PER_PAGE, {
filter: `tenant = "${tenantId}"`,
expand: 'station',
sort: '-created'
});
logs = result.items;
page = p;
totalPages = Math.ceil(result.totalItems / PER_PAGE);
loading = false;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit'
});
}
</script>
<svelte:head><title>Protokoll — checkflo</title></svelte:head>
<div class="page">
<h1>Protokoll</h1>
{#if loading}
<p class="hint">Lädt…</p>
{:else if logs.length === 0}
<p class="hint">Noch keine Einträge vorhanden.</p>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Station</th>
<th>Status</th>
<th>Temp.</th>
<th>Person</th>
<th>Notiz</th>
<th>Datum</th>
</tr>
</thead>
<tbody>
{#each logs as log}
<tr>
<td class="td-station">{log.expand?.station?.name ?? '—'}</td>
<td>
<span class="badge" style="background:{STATUS_COLOR[log.status]}20;color:{STATUS_COLOR[log.status]}">
{STATUS_LABEL[log.status]}
</span>
</td>
<td>{log.temperature ? log.temperature + ' °C' : '—'}</td>
<td>{log.checked_by}</td>
<td class="td-notes">{log.notes || '—'}</td>
<td class="td-date">{formatDate(log.created)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if totalPages > 1}
<div class="pagination">
<button disabled={page === 1} onclick={() => loadPage(page - 1)}>← zurück</button>
<span>Seite {page} / {totalPages}</span>
<button disabled={page === totalPages} onclick={() => loadPage(page + 1)}>weiter →</button>
</div>
{/if}
{/if}
</div>
<style>
.page { max-width: 1000px; }
h1 { font-size: 1.6rem; font-weight: 800; color: #0B1023; margin-bottom: 1.5rem; }
.hint { color: #aaa; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
th { background: #F5F7FA; padding: 0.75rem 1rem; text-align: left; font-size: 0.8rem; font-weight: 700; color: #888; text-transform: uppercase; letter-spacing: 0.5px; }
td { padding: 0.75rem 1rem; border-top: 1px solid #f0f0f0; font-size: 0.9rem; vertical-align: middle; }
tr:hover td { background: #fafafa; }
.badge { padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.8rem; font-weight: 700; white-space: nowrap; }
.td-station { font-weight: 600; }
.td-notes { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #666; }
.td-date { white-space: nowrap; color: #888; font-size: 0.85rem; }
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
font-size: 0.9rem;
color: #666;
}
.pagination button {
padding: 0.5rem 1rem;
border: 1.5px solid #ddd;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.15s;
}
.pagination button:hover:not(:disabled) { border-color: #F97316; color: #F97316; }
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
</style>

View file

@ -0,0 +1,141 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { onMount } from 'svelte';
const TYPE_LABEL: Record<string, string> = {
kuehlschrank: 'Kühlschrank',
tiefkuehl: 'Tiefkühl',
warmhalte: 'Warmhalte',
hygiene: 'Hygiene',
sonstiges: 'Sonstiges'
};
let stations = $state<any[]>([]);
let loading = $state(true);
let copied = $state<string | null>(null);
onMount(async () => {
const tenantId = (pb.authStore.record as any)?.tenant;
const result = await pb.collection('stations').getFullList({
filter: `tenant = "${tenantId}" && active = true`,
sort: 'name'
});
stations = result;
loading = false;
});
function qrUrl(qrId: string) {
return `https://checkflo.de/s/${qrId}`;
}
async function copyUrl(qrId: string) {
await navigator.clipboard.writeText(qrUrl(qrId));
copied = qrId;
setTimeout(() => copied = null, 2000);
}
</script>
<svelte:head><title>Stationen — checkflo</title></svelte:head>
<div class="page">
<h1>Stationen</h1>
<p class="hint-text">Jeden QR-Code ausdrucken und an der Station befestigen. Der Link öffnet die Checkliste direkt im Browser.</p>
{#if loading}
<p class="hint">Lädt…</p>
{:else}
<div class="list">
{#each stations as s}
<div class="card">
<div class="card-head">
<div>
<div class="station-name">{s.name}</div>
<div class="station-type">{TYPE_LABEL[s.type] ?? s.type}
{#if s.target_temp_min || s.target_temp_max}
· {s.target_temp_min}° bis {s.target_temp_max}°C
{/if}
</div>
</div>
</div>
<div class="qr-row">
<code class="qr-url">{qrUrl(s.qr_id)}</code>
<button class="btn-copy" onclick={() => copyUrl(s.qr_id)}>
{copied === s.qr_id ? '✓ Kopiert' : 'Kopieren'}
</button>
<a
href="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encodeURIComponent(qrUrl(s.qr_id))}"
target="_blank"
class="btn-qr"
>
QR öffnen
</a>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.page { max-width: 800px; }
h1 { font-size: 1.6rem; font-weight: 800; color: #0B1023; margin-bottom: 0.5rem; }
.hint-text { color: #666; font-size: 0.9rem; margin-bottom: 2rem; }
.hint { color: #aaa; }
.list { display: flex; flex-direction: column; gap: 1rem; }
.card {
background: #fff;
border-radius: 12px;
padding: 1.25rem 1.5rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.card-head { margin-bottom: 1rem; }
.station-name { font-weight: 700; font-size: 1.05rem; color: #0B1023; }
.station-type { font-size: 0.85rem; color: #888; margin-top: 0.2rem; }
.qr-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.qr-url {
flex: 1;
font-size: 0.8rem;
background: #F5F7FA;
padding: 0.5rem 0.75rem;
border-radius: 6px;
color: #555;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-copy, .btn-qr {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.btn-copy {
background: #F5F7FA;
border: 1.5px solid #ddd;
color: #555;
}
.btn-copy:hover { border-color: #F97316; color: #F97316; }
.btn-qr {
background: #F97316;
color: #fff;
border: none;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.btn-qr:hover { background: #ea6c10; }
</style>

View file

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {

View file

@ -1,6 +1,38 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()] plugins: [
sveltekit(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'checkflo — HACCP-Protokolle',
short_name: 'checkflo',
description: 'Digitale HACCP-Dokumentation für die Gastronomie',
theme_color: '#0B1023',
background_color: '#ffffff',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.checkflo\.de\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'pocketbase-api',
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 }
}
}
]
}
})
]
}); });

35
docker-compose.yml Normal file
View file

@ -0,0 +1,35 @@
version: "3.8"
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: checkflo-pocketbase
restart: unless-stopped
volumes:
- /volume1/docker/checkflo/pocketbase/data:/pb_data
- /volume1/docker/checkflo/pocketbase/storage:/pb_public
environment:
- TZ=Europe/Berlin
networks:
- default
- npm_bridge
app:
build:
context: ./app
dockerfile: Dockerfile
image: checkflo-app
container_name: checkflo-app
restart: unless-stopped
environment:
- TZ=Europe/Berlin
- HOST=0.0.0.0
- PORT=3000
networks:
- default
- npm_bridge
networks:
npm_bridge:
external: true
name: nginx-proxy-manager_bridge_net