2 Commits
8 changed files with 1248 additions and 1153 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
# 1. Install dependencies
FROM node:alpine AS deps
FROM node:25-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package*.json ./
RUN npm ci
# 2. Build the app
FROM node:alpine AS builder
FROM node:25-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -18,7 +18,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# 3. Production image (Slimmad!)
FROM node:alpine AS runner
FROM node:25-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
+115
View File
@@ -0,0 +1,115 @@
// app/admin/NoticeTab.tsx
"use client";
import { AlertCircle, AlertTriangle, BellRing, Info, Loader2, Save, CheckCircle } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { readJsonFile, writeJsonFile } from '../actions/jsonEditor';
interface NoticeData {
isActive: boolean;
type: 'notice' | 'warning' | 'important';
message: string;
}
export const NoticeTab = ({ isOffline }: { isOffline: boolean }) => {
const [notice, setNotice] = useState<NoticeData>({ isActive: false, type: 'warning', message: '' });
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
useEffect(() => {
const fetchNotice = async () => {
const res = await readJsonFile('notice.json');
if (res.success && res.data) {
setNotice(res.data);
}
setIsLoading(false);
};
fetchNotice();
}, []);
const handleSave = async () => {
setIsSaving(true);
setSaveStatus('idle');
const res = await writeJsonFile('notice.json', notice);
if (res.success) {
setSaveStatus('success');
setTimeout(() => setSaveStatus('idle'), 3000);
} else {
setSaveStatus('error');
}
setIsSaving(false);
};
if (isLoading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-slate-teal" size={32} /></div>;
return (
<div className="space-y-6 animate-fade-in">
<div className="bg-eggshell border border-slate-teal/10 p-5 md:p-8 rounded-3xl shadow-sm">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-black text-ebony uppercase tracking-widest flex items-center">
<div className="bg-white p-2.5 rounded-xl mr-3 text-slate-teal shadow-sm">
<BellRing size={24} />
</div>
Meddelande startsidan
</h2>
{/* Av/På Switch */}
<button
onClick={() => setNotice({ ...notice, isActive: !notice.isActive })}
disabled={isOffline}
className={`relative inline-flex h-7 w-14 items-center rounded-full transition-colors focus:outline-none shadow-inner disabled:opacity-50 ${notice.isActive ? 'bg-moss' : 'bg-slate-teal/20'}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform shadow-sm ${notice.isActive ? 'translate-x-8' : 'translate-x-1'}`} />
</button>
</div>
<div className={`transition-all duration-300 ${!notice.isActive ? 'opacity-40 grayscale pointer-events-none' : ''}`}>
<div className="space-y-6">
<div>
<label className="block text-sm font-black text-ebony/60 uppercase tracking-widest mb-2">Typ av meddelande</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<button onClick={() => setNotice({ ...notice, type: 'notice' })} className={`flex items-center p-3 rounded-xl border-2 transition-all font-bold text-sm ${notice.type === 'notice' ? 'bg-seafoam/10 border-seafoam text-slate-teal' : 'bg-white border-transparent text-ebony/60 hover:bg-white/80 shadow-sm'}`}>
<Info size={18} className="mr-2" /> Information
</button>
<button onClick={() => setNotice({ ...notice, type: 'warning' })} className={`flex items-center p-3 rounded-xl border-2 transition-all font-bold text-sm ${notice.type === 'warning' ? 'bg-goldenrod/10 border-goldenrod text-goldenrod' : 'bg-white border-transparent text-ebony/60 hover:bg-white/80 shadow-sm'}`}>
<AlertTriangle size={18} className="mr-2" /> Varning
</button>
<button onClick={() => setNotice({ ...notice, type: 'important' })} className={`flex items-center p-3 rounded-xl border-2 transition-all font-bold text-sm ${notice.type === 'important' ? 'bg-emergency/10 border-emergency text-emergency' : 'bg-white border-transparent text-ebony/60 hover:bg-white/80 shadow-sm'}`}>
<AlertCircle size={18} className="mr-2" /> Akut / Viktigt
</button>
</div>
</div>
<div>
<label className="block text-sm font-black text-ebony/60 uppercase tracking-widest mb-2">Text</label>
<textarea
value={notice.message}
onChange={(e) => setNotice({ ...notice, message: e.target.value })}
placeholder="Skriv ditt meddelande här..."
className="w-full h-32 bg-white border border-slate-teal/10 p-4 rounded-2xl text-sm font-bold resize-none focus:outline-none focus:border-slate-teal shadow-sm"
/>
</div>
</div>
</div>
<div className="mt-8 flex items-center justify-between border-t border-slate-teal/10 pt-6">
<p className="text-xs font-bold text-slate-teal/60">
{saveStatus === 'success' && <span className="text-moss flex items-center"><CheckCircle size={14} className="mr-1" /> Sparat och live!</span>}
{saveStatus === 'error' && <span className="text-emergency flex items-center"><AlertTriangle size={14} className="mr-1" /> Kunde inte spara.</span>}
{isOffline && "Du är offline. Går ej att uppdatera."}
</p>
<button
onClick={handleSave}
disabled={isOffline || isSaving}
className="bg-slate-teal text-eggshell font-black uppercase tracking-widest text-xs px-6 py-3 rounded-xl hover:bg-ebony transition-colors disabled:opacity-50 shadow-sm flex items-center gap-2"
>
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
Spara ändringar
</button>
</div>
</div>
</div>
);
};
+5 -2
View File
@@ -2,7 +2,7 @@
"use client";
import { CalendarRange, ClipboardCheck, Loader2, Lock, Unlock, Users as UsersIcon } from 'lucide-react';
import { CalendarRange, ClipboardCheck, Loader2, Lock, Unlock, Users as UsersIcon, BellRing } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { verifyLogin } from '../actions/admin';
import { OfflineBadge } from '../components/ui/OfflineBadge';
@@ -10,6 +10,7 @@ import { AppUser } from './adminTypes';
import { AttendanceTab } from './AttendanceTab';
import { ReportTab } from './ReportTab';
import { SetupTab } from './SetupTab';
import { NoticeTab } from './NoticeTab';
import { useAdminState } from './useAdminState';
export default function Admin() {
@@ -22,7 +23,7 @@ export default function Admin() {
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState(false);
const [activeTab, setActiveTab] = useState<'setup' | 'today' | 'report'>('report');
const [activeTab, setActiveTab] = useState<'setup' | 'notice' | 'today' | 'report'>('report');
const adminState = useAdminState();
useEffect(() => {
@@ -122,6 +123,7 @@ export default function Admin() {
const availableTabs = [];
if (currentUser.role === 'Admin') availableTabs.push({ id: 'setup', icon: CalendarRange, label: 'Perioder' });
if (currentUser.role === 'Admin') availableTabs.push({ id: 'notice', icon: BellRing, label: 'Notis' });
if (currentUser.role === 'Admin' || currentUser.role === 'Staff') availableTabs.push({ id: 'today', icon: ClipboardCheck, label: 'Närvaro' });
availableTabs.push({ id: 'report', icon: UsersIcon, label: 'Rapport' });
@@ -164,6 +166,7 @@ export default function Admin() {
)}
{activeTab === 'setup' && currentUser.role === 'Admin' && <SetupTab {...adminState} />}
{activeTab === 'notice' && currentUser.role === 'Admin' && <NoticeTab isOffline={adminState.isOffline} />}
{activeTab === 'today' && (currentUser.role === 'Admin' || currentUser.role === 'Staff') && (
<AttendanceTab
periods={adminState.periods}
+18 -27
View File
@@ -1,12 +1,12 @@
// app/components/ui/SectionCard.tsx
import React, { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
import { ReactNode } from 'react';
interface SectionCardProps {
title: string;
icon?: LucideIcon;
children: ReactNode;
children?: ReactNode;
description?: ReactNode;
variant?: 'default' | 'alert' | 'highlight';
className?: string;
@@ -21,65 +21,56 @@ export function SectionCard({
className = ""
}: SectionCardProps) {
// Define base styles for the card container
let cardStyle = "rounded-3xl shadow-sm transition-all duration-300 ";
let headerStyle = "text-xl font-black uppercase tracking-widest mb-4 flex items-center ";
let cardStyle = "rounded-3xl shadow-sm transition-all duration-300 p-6 md:p-8 hover:shadow-md ";
let headerStyle = "text-xl font-black uppercase tracking-widest flex items-center ";
let iconStyle = "mr-3 shrink-0 ";
let descStyle = "text-sm mb-6 leading-relaxed ";
let descStyle = "text-sm leading-relaxed ";
// Apply specific styles based on the chosen variant
switch (variant) {
case 'alert':
cardStyle += "bg-emergency/10 border-2 border-emergency/30 p-6 md:p-8 hover:shadow-md";
cardStyle += "bg-emergency/10 backdrop-blur-md border-2 border-emergency/30";
headerStyle += "text-emergency";
iconStyle += "text-emergency";
descStyle += "text-emergency/90 font-bold";
break;
case 'highlight':
cardStyle += "bg-goldenrod/30 border border-goldenrod/20 p-5";
headerStyle += "text-sm text-goldenrod mb-1";
iconStyle += "text-goldenrod mt-0.5";
descStyle += "text-ebony font-medium m-0";
cardStyle += "bg-goldenrod/30 backdrop-blur-md border border-goldenrod/20";
headerStyle += "text-goldenrod";
iconStyle += "text-goldenrod";
descStyle += "text-ebony font-medium";
break;
case 'default':
default:
cardStyle += "bg-white/40 backdrop-blur-md border border-white/40 p-6 md:p-8 hover:shadow-md";
cardStyle += "bg-white/40 backdrop-blur-md border border-white/40";
headerStyle += "text-ebony";
iconStyle += "text-slate-teal";
descStyle += "text-ebony/80 font-medium";
break;
}
const hasChildren = React.Children.toArray(children).length > 0;
return (
<section className={`${cardStyle} ${className}`}>
<div className={variant === 'highlight' ? 'flex items-start' : ''}>
{variant === 'highlight' && Icon && (
<Icon className={iconStyle} size={24} />
)}
<div className={variant === 'highlight' ? 'flex-1' : ''}>
<h2 className={headerStyle}>
{variant !== 'highlight' && Icon && <Icon className={iconStyle} size={24} />}
<h2 className={`${headerStyle} ${(description || hasChildren) ? 'mb-4' : 'mb-0'}`}>
{Icon && <Icon className={iconStyle} size={24} />}
{title}
</h2>
{description && (
<div className={descStyle}>
<div className={`${descStyle} ${hasChildren ? 'mb-6' : 'mb-0'}`}>
{description}
</div>
)}
{variant === 'highlight' ? (
<div className="text-sm font-medium leading-relaxed text-ebony">
{hasChildren && (
<div className={variant === 'highlight' ? "text-sm font-medium leading-relaxed text-ebony" : ""}>
{children}
</div>
) : (
children
)}
</div>
</div>
</section>
);
}
+5
View File
@@ -0,0 +1,5 @@
{
"isActive": true,
"type": "important",
"message": "Glöm inte minst 1 liter vatten, solkräm och myggmedel. Det förväntas bli mycket varmt idag!"
}
+50 -8
View File
@@ -1,11 +1,48 @@
// app/page.tsx
"use client";
import { AlertTriangle, PhoneCall } from 'lucide-react';
import { AlertCircle, AlertTriangle, Info, Loader2, PhoneCall } from 'lucide-react';
import { useEffect, useState } from 'react';
import { readJsonFile } from './actions/jsonEditor';
import { SafePhoneLink } from './components/PhoneLinks';
import { ActionLinkCard } from './components/ui/ActionLinkCard';
import { SectionCard } from './components/ui/SectionCard';
import { ActionLinkCard } from './components/ui/ActionLinkCard'; // <-- Import the new card
const getNoticeConfig = (type: string) => {
switch (type) {
case 'important':
return { icon: AlertCircle, title: 'Viktigt Meddelande', variant: 'alert' as const };
case 'notice':
return { icon: Info, title: 'Information', variant: undefined };
case 'warning':
default:
return { icon: AlertTriangle, title: 'Dagens Påminnelse', variant: 'highlight' as const };
}
};
export default function Home() {
const [notice, setNotice] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadNotice = async () => {
if (navigator.onLine) {
const res = await readJsonFile('notice.json');
if (res.success && res.data) {
setNotice(res.data);
localStorage.setItem('kullaberg_notice_cache', JSON.stringify(res.data));
}
} else {
const cached = localStorage.getItem('kullaberg_notice_cache');
if (cached) setNotice(JSON.parse(cached));
}
setIsLoading(false);
};
loadNotice();
}, []);
const nConfig = notice ? getNoticeConfig(notice.type) : null;
return (
<div className="space-y-6 animate-fade-in w-full mx-auto">
{/* Welcome Banner */}
@@ -15,13 +52,18 @@ export default function Home() {
</div>
{/* Daily Notice Card */}
{isLoading ? (
<div className="flex justify-center py-4"><Loader2 className="animate-spin text-slate-teal" size={24} /></div>
) : (
notice && notice.isActive && nConfig && (
<SectionCard
title="Dagens Påminnelse"
icon={AlertTriangle}
variant="highlight"
>
Glöm inte minst 1 liter vatten, solkräm och myggmedel. Det förväntas bli mycket varmt idag!
</SectionCard>
title={nConfig.title}
icon={nConfig.icon}
variant={nConfig.variant}
description={notice.message}
/>
)
)}
{/* Quick Action Cards now use the glass style */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+1038 -1101
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -9,11 +9,13 @@
"lint": "eslint"
},
"dependencies": {
"@hono/node-server": "^2.0.1",
"@prisma/adapter-better-sqlite3": "^7.5.0",
"@prisma/client": "^7.5.0",
"@serwist/next": "^9.5.7",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.3.1",
"hono": "^4.12.18",
"localforage": "^1.10.0",
"lucide-react": "^0.577.0",
"next": "^16.2.1",
@@ -36,8 +38,8 @@
"typescript": "^5"
},
"overrides": {
"hono": "4.12.7",
"@hono/node-server": "1.19.10",
"postcss": "^8.5.10",
"@hono/node-server": "$@hono/node-server",
"effect": "^3.20.0",
"@eslint/plugin-kit": "^0.3.4",
"brace-expansion": "^5.0.1",