Compare commits
+3
-3
@@ -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
|
||||
|
||||
@@ -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 på startsidan
|
||||
</h2>
|
||||
|
||||
{/* Av/På Switch */}
|
||||
<button
|
||||
onClick={() => setNotice({ ...notice, isActive: !notice.isActive })}
|
||||
disabled={isOffline}
|
||||
className={`relative inline-flex h-7 w-12 shrink-0 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-6' : '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>
|
||||
);
|
||||
};
|
||||
+6
-3
@@ -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(() => {
|
||||
@@ -104,7 +105,7 @@ export default function Admin() {
|
||||
type="password"
|
||||
ref={pinRef}
|
||||
placeholder="•••••"
|
||||
className={`w-full bg-white border text-center text-2xl text-ebony font-mono p-3 rounded-xl focus:outline-none ${loginError ? 'border-emergency/50 bg-emergency/5' : 'border-slate-teal/20 focus:border-slate-teal'}`}
|
||||
className={`w-full bg-white border text-center text-2xl text-ebony font-mono p-3 rounded-xl tracking-widest focus:outline-none ${loginError ? 'border-emergency/50 bg-emergency/5' : 'border-slate-teal/20 focus:border-slate-teal'}`}
|
||||
onChange={() => setLoginError(false)}
|
||||
/>
|
||||
</div>
|
||||
@@ -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' || currentUser.role === 'Staff') 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' || currentUser.role === 'Staff') && <NoticeTab isOffline={adminState.isOffline} />}
|
||||
{activeTab === 'today' && (currentUser.role === 'Admin' || currentUser.role === 'Staff') && (
|
||||
<AttendanceTab
|
||||
periods={adminState.periods}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Generated
+1038
-1101
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user