Er skraping ferdig nå?

This commit is contained in:
Erol 2026-03-05 09:25:15 +01:00
parent 78e7d2b12e
commit 726785dfcc
5 changed files with 283 additions and 68 deletions

View file

@ -36,10 +36,16 @@ ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
# --- PYDANTIC MODELLER ---
class CourseStatusUpdate(BaseModel):
id: int
status: str
class ScrapeSettingsUpdate(BaseModel):
scrape_method: Optional[str] = None
scrape_status_url: Optional[str] = None
scrape_status_selector: Optional[str] = None
ai_instruction: Optional[str] = None
courses: Optional[List[CourseStatusUpdate]] = []
# NY MODELL FOR Å TA IMOT IDER FOR SCRAPING
class ScrapeRunRequest(BaseModel):
@ -238,7 +244,7 @@ async def get_facilities():
rows = await conn.fetch("""
SELECT f.*, (
SELECT jsonb_agg(cs) FROM (
SELECT name, status FROM courses
SELECT id, name, status FROM courses
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
ORDER BY is_main_course DESC, id ASC
) cs
@ -285,19 +291,26 @@ async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdat
if not facility:
raise HTTPException(status_code=404, detail="Anlegget finnes ikke.")
# Oppdater verdiene i databasen
# Oppdater verdiene i databasen inkludert AI instruks
await conn.execute("""
UPDATE facilities
SET scrape_method = $1,
scrape_status_url = $2,
scrape_status_selector = $3
WHERE id = $4
scrape_status_selector = $3,
ai_instruction = $4
WHERE id = $5
""",
settings.scrape_method,
settings.scrape_status_url,
settings.scrape_status_selector,
settings.ai_instruction,
facility_id)
# Hvis metoden er manuell, tvinger vi gjennom de nye banestatusene direkte
if settings.scrape_method == 'manual' and settings.courses:
for c in settings.courses:
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", c.status, c.id)
return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."}
except Exception as e:

View file

@ -26,7 +26,7 @@ DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_pass
# Den nye pakken henter automatisk GEMINI_API_KEY fra .env-filen din
client = genai.Client()
async def ask_llm_status(text, course_name, is_single_course):
async def ask_llm_status(text, course_name, is_single_course, ai_instruction=None):
"""Sender teksten til Gemini og ber om ett enkelt status-ord tilbake."""
# 1. Dynamisk instruks basert på antall baner
@ -35,10 +35,14 @@ async def ask_llm_status(text, course_name, is_single_course):
else:
bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".'
# NYTT: Hvisker inn i øret til AI-en hvis vi har en instruks fra admin
ekstra_tekst = f"\nVIKTIG EKSTRA-INSTRUKS FRA ADMIN:\n{ai_instruction}\n" if ai_instruction else ""
# 2. Selve promptet
prompt = f"""
Du er en ekspert å lese norske golfklubbers nettsider for å finne banestatus.
{bane_instruks}
{ekstra_tekst}
Svar KUN med nøyaktig ETT av disse ordene:
- aapen (hvis banen er åpen/sommergreener)
- stengt (hvis banen er lukket/stengt/frost/snø)
@ -146,13 +150,13 @@ async def run_daily_scraping(facility_ids=None):
if facility_ids:
print(f"📌 Kjører skraping KUN for anlegg-ID(er): {facility_ids}")
facilities = await conn.fetch(
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL AND id = ANY($1::int[])",
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method, ai_instruction FROM facilities WHERE scrape_status_url IS NOT NULL AND id = ANY($1::int[])",
facility_ids
)
else:
print("🌍 Kjører skraping for ALLE anlegg med scrape_status_url...")
facilities = await conn.fetch(
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL"
"SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method, ai_instruction FROM facilities WHERE scrape_status_url IS NOT NULL"
)
if not facilities:
@ -168,17 +172,24 @@ async def run_daily_scraping(facility_ids=None):
context = await browser.new_context()
for f in facilities:
method = f.get('scrape_method') or 'css_selector'
# THE KILL SWITCH - Hopper over manuelle baner
if method == 'manual':
successes.append(f"⏸️ {f['name']}: Hoppet over (Manuell overstyring)")
print(f" ⏸️ Hopper over skraping av {f['name']} (Satt til Manuell)")
continue
page = await context.new_page()
try: await apply_stealth(page)
except: pass
try:
print(f"🔍 Besøker {f['name']} (Metode: {f.get('scrape_method') or 'css_selector'})...")
print(f"🔍 Besøker {f['name']} (Metode: {method})...")
await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded")
await asyncio.sleep(3) # Gir Javascript 3 sekunder på å bygge siden
full_text = ""
method = f.get('scrape_method') or 'css_selector'
if method == 'css_selector':
element = page.locator(f['scrape_status_selector']).first
@ -259,7 +270,7 @@ async def run_daily_scraping(facility_ids=None):
# HENTER STATUS VIA AI ELLER GAMMEL METODE
if method == 'llm_parse':
print(f" 🤖 Spør Gemini om status for '{c['name']}' (Singelbane: {is_single_course})...")
new_status = await ask_llm_status(full_text, c['name'], is_single_course)
new_status = await ask_llm_status(full_text, c['name'], is_single_course, f.get('ai_instruction'))
else:
new_status = interpret_status(full_text, c['scrape_keyword'])

View file

@ -1,25 +1,38 @@
"use client";
/**
* TEE OFF ADMIN DASHBOARD v1.4 - LIVE PROGRESSION
* TEE OFF ADMIN DASHBOARD v1.7 - RESPONSIVT MED AI-HVISKER & KILL SWITCH
* ---------------------------------------------------------------------------
* PLASSERING: frontend/src/app/admin/page.tsx
* FUNKSJON: Starter bakgrunnsjobber og oppdaterer tabellen live.
* FUNKSJON: Live-oppdatering, massevalg, redigering og SAMMENTREKKBAR meny.
* ---------------------------------------------------------------------------
*/
import { useState, useEffect } from 'react';
import { API_URL } from "@/config/constants";
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
export default function AdminDashboard() {
const [facilities, setFacilities] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
// NYTT: Holder styr på om en skraping pågår akkurat nå
const [isScraping, setIsScraping] = useState(false);
// Henter data fra databasen
// NY: State for å sjekke om sidebaren er klappet sammen
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [editingFacility, setEditingFacility] = useState<any | null>(null);
// OPPDATERT: Lagt til ai_instruction og courses for manuell overstyring
const [editForm, setEditForm] = useState({
scrape_status_url: '',
scrape_status_selector: '',
scrape_method: '',
ai_instruction: '',
courses: [] as any[]
});
const [isSaving, setIsSaving] = useState(false);
const fetchFacilities = () => {
fetch(`${API_URL}/facilities`)
.then(res => res.json())
@ -30,12 +43,10 @@ export default function AdminDashboard() {
.catch(() => setLoading(false));
};
// Hent data ved første innlasting
useEffect(() => {
fetchFacilities();
}, []);
// NYTT: Hvis skraping pågår, oppdater tabellen hvert 10. sekund!
useEffect(() => {
let interval: NodeJS.Timeout;
if (isScraping) {
@ -62,8 +73,13 @@ export default function AdminDashboard() {
}
};
// NYTT: Sender IDene til API-et og starter auto-oppdatering
const handleRunScrapers = async () => {
const handleRunScrapers = async () => {
// Hvis den allerede skraper, lar vi brukeren trykke på knappen for å avbryte UI-oppdateringen
if (isScraping) {
setIsScraping(false);
return;
}
setIsScraping(true);
try {
const response = await fetch(`${API_URL}/admin/run-scraper`, {
@ -71,15 +87,13 @@ export default function AdminDashboard() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ facility_ids: selectedFacilities })
});
if (!response.ok) throw new Error("Kunne ikke starte skraping");
// Valgfritt: Fjern avhukingene når jobben har startet
setSelectedFacilities([]);
// Beregn dynamisk timeout: 40 sekunder per valgte anlegg (Minimum 1 minutt)
const timeoutMs = Math.max(selectedFacilities.length * 40 * 1000, 60000);
// Stopper auto-oppdateringen etter 10 minutter (for sikkerhets skyld)
setTimeout(() => setIsScraping(false), 10 * 60 * 1000);
setSelectedFacilities([]);
setTimeout(() => setIsScraping(false), timeoutMs);
} catch (error) {
console.error(error);
alert("Feil ved start av skraperen.");
@ -87,49 +101,216 @@ export default function AdminDashboard() {
}
};
// OPPDATERT: Laster inn eksisterende banestatuser og ai_instruction
const openEditModal = (facility: any) => {
setEditingFacility(facility);
setEditForm({
scrape_status_url: facility.scrape_status_url || '',
scrape_status_selector: facility.scrape_status_selector || '',
scrape_method: facility.scrape_method || 'css_selector',
ai_instruction: facility.ai_instruction || '',
courses: facility.course_statuses ? facility.course_statuses.map((c: any) => ({id: c.id, name: c.name, status: c.status})) : []
});
};
const handleSaveEdit = async () => {
setIsSaving(true);
try {
const response = await fetch(`${API_URL}/admin/facilities/${editingFacility.id}/scrape-settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm)
});
if (!response.ok) throw new Error("Feil ved lagring");
setEditingFacility(null);
fetchFacilities();
} catch (error) {
alert("Kunne ikke lagre endringene.");
console.error(error);
} finally {
setIsSaving(false);
}
};
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>;
return (
<div className="flex flex-col lg:flex-row min-h-screen bg-[#f1f7ed] font-sans">
<aside className="lg:w-[22%] bg-[#11280f] text-white p-10 flex flex-col">
<h1 className="text-2xl font-black uppercase tracking-tighter mb-10">TeeOff Admin</h1>
<div className="flex min-h-screen bg-[#f1f7ed] font-sans relative overflow-hidden">
{/* REDIGER-MODAL */}
{editingFacility && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
<div className="bg-[#11280f] text-white p-6 shrink-0">
<h3 className="text-xl font-black uppercase tracking-widest">Rediger Konfigurasjon</h3>
<p className="text-sm text-[#7ca982]">{editingFacility.name}</p>
</div>
<div className="p-8 space-y-6 overflow-y-auto flex-grow">
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Scrape URL</label>
<input
type="text"
value={editForm.scrape_status_url}
onChange={(e) => setEditForm({...editForm, scrape_status_url: e.target.value})}
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
placeholder="f.eks. https://golfklubb.no/banestatus"
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Skrapemetode</label>
<select
value={editForm.scrape_method}
onChange={(e) => setEditForm({...editForm, scrape_method: e.target.value})}
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
>
<option value="css_selector">Standard (CSS)</option>
<option value="llm_parse"> Gemini AI (LLM)</option>
<option value="iframe_golfbox">Golfbox iframe</option>
<option value="click_then_css">Auto-klikk + CSS</option>
<option value="manual">🚨 Manuell (Ikke skrap)</option>
</select>
</div>
{/* Boks som vises hvis Gemini AI er valgt */}
{editForm.scrape_method === 'llm_parse' && (
<div className="animate-fade-in">
<label className="block text-xs font-bold text-[#8bc34a] uppercase tracking-widest mb-2"> AI-Hviskeren (Instruks til Gemini)</label>
<textarea
value={editForm.ai_instruction || ''}
onChange={(e) => setEditForm({...editForm, ai_instruction: e.target.value})}
className="w-full border-2 border-[#8bc34a]/30 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors"
placeholder="F.eks: Ignorer info om korthullsbanen. Banen er åpen."
rows={3}
/>
<p className="text-[10px] text-gray-400 mt-1">Hjelper Gemini hvis nettsiden er forvirrende.</p>
</div>
)}
{/* Boks som vises hvis MANUELL er valgt */}
{editForm.scrape_method === 'manual' && (
<div className="bg-red-50 border border-red-100 rounded-xl p-4 animate-fade-in">
<label className="block text-xs font-black text-red-500 uppercase tracking-widest mb-4">🚨 Sett Status Manuelt</label>
<div className="space-y-4">
{editForm.courses.map((course: any, idx: number) => (
<div key={course.id} className="flex justify-between items-center bg-white p-3 rounded-lg shadow-sm">
<span className="text-xs font-bold text-gray-700 uppercase tracking-widest truncate mr-2" title={course.name}>{course.name}</span>
<select
value={course.status || 'ukjent'}
onChange={(e) => {
const newCourses = [...editForm.courses];
newCourses[idx].status = e.target.value;
setEditForm({...editForm, courses: newCourses});
}}
className="border border-gray-200 rounded-lg p-2 text-xs font-bold focus:outline-none focus:border-red-400 shrink-0"
>
<option value="aapen">🟢 Åpen</option>
<option value="aapen_med_vintergreener">🟡 Vintergreener</option>
<option value="aapner_snart">🟡 Åpner Snart</option>
<option value="stengt">🔴 Stengt</option>
<option value="stenger_snart">🔴 Stenger Snart</option>
<option value="under_utvikling">🔨 Under Utvikling</option>
<option value="nedlagt"> Nedlagt</option>
<option value="ukjent"> Ukjent</option>
</select>
</div>
))}
</div>
</div>
)}
{/* Boks for gammel CSS-skraping */}
{(editForm.scrape_method === 'css_selector' || editForm.scrape_method === 'click_then_css' || editForm.scrape_method === 'iframe_golfbox') && (
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">CSS Selector</label>
<input
type="text"
value={editForm.scrape_status_selector}
onChange={(e) => setEditForm({...editForm, scrape_status_selector: e.target.value})}
className="w-full border-2 border-gray-100 rounded-xl p-3 text-sm focus:border-[#8bc34a] focus:outline-none transition-colors font-mono"
placeholder="f.eks. .status-text"
/>
</div>
)}
</div>
<div className="bg-gray-50 p-6 flex justify-end gap-4 shrink-0">
<button onClick={() => setEditingFacility(null)} className="px-6 py-3 rounded-xl text-xs font-bold uppercase tracking-widest text-gray-500 hover:bg-gray-200 transition-colors">Avbryt</button>
<button onClick={handleSaveEdit} disabled={isSaving} className="bg-[#8bc34a] text-white px-6 py-3 rounded-xl text-xs font-black uppercase tracking-widest shadow-lg hover:scale-105 transition-all disabled:opacity-50">
{isSaving ? 'Lagrer...' : 'Lagre endringer'}
</button>
</div>
</div>
</div>
)}
{/* NY SIDEBAR: Responsiv og sammentrekkbar */}
<aside className={`bg-[#11280f] text-white flex flex-col transition-all duration-300 shrink-0 ${isSidebarCollapsed ? 'w-16 p-4' : 'w-64 p-8'} hidden md:flex`}>
<div className={`flex items-center mb-10 ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
{!isSidebarCollapsed && <h1 className="text-2xl font-black uppercase tracking-tighter">TeeOff</h1>}
<button onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} className="text-2xl hover:text-[#8bc34a] transition-colors" title="Skjul/Vis meny">
</button>
</div>
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
<div className="text-white border-l-4 border-[#8bc34a] pl-4 py-1">Scraping Monitor</div>
<div className="hover:text-white cursor-pointer pl-4 py-1 transition-colors">Medlemskap</div>
<div className="hover:text-white cursor-pointer pl-4 py-1 transition-colors">Bildegalleri</div>
<div className={`text-white border-l-4 border-[#8bc34a] py-1 ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4'}`} title="Scraping Monitor">
{isSidebarCollapsed ? 'SM' : 'Scraping Monitor'}
</div>
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Medlemskap">
{isSidebarCollapsed ? 'M' : 'Medlemskap'}
</div>
<div className={`hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Bildegalleri">
{isSidebarCollapsed ? 'B' : 'Bildegalleri'}
</div>
</nav>
<div className="mt-auto pt-10 border-t border-white/10">
<button onClick={() => window.location.href='/'} className="text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300">Logg ut</button>
<div className={`mt-auto pt-8 border-t border-white/10 ${isSidebarCollapsed ? 'text-center' : ''}`}>
<button onClick={() => window.location.href='/'} className={`text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300 ${isSidebarCollapsed ? 'writing-vertical' : ''}`} title="Logg ut">
{isSidebarCollapsed ? 'UT' : 'Logg ut'}
</button>
</div>
</aside>
<main className="lg:w-[78%] p-6 md:p-12">
<div className="bg-white rounded-[3rem] shadow-2xl p-10 md:p-16 border border-white">
<header className="flex justify-between items-center mb-12">
{/* HOVEDINNHOLD: flex-1 og min-w-0 løser responsivitetsproblemet */}
<main className="flex-1 min-w-0 p-4 md:p-8 lg:p-10 h-screen overflow-y-auto">
{/* Mobil-meny (Vises bare på veldig små skjermer) */}
<div className="md:hidden flex justify-between items-center mb-6 bg-[#11280f] text-white p-4 rounded-2xl">
<h1 className="text-xl font-black uppercase tracking-tighter">TeeOff Admin</h1>
<span className="text-xs font-bold text-[#8bc34a]">MONITOR</span>
</div>
{/* Mindre padding på mobil/tablet (p-6) for å spare plass */}
<div className="bg-white rounded-[2rem] shadow-2xl p-6 lg:p-10 border border-white">
<header className="flex flex-col xl:flex-row justify-between items-start xl:items-center gap-6 mb-10">
<div>
<h2 className="text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2>
<h2 className="text-3xl md:text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2>
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status {facilities.length} anlegg</p>
</div>
{/* NYTT: Knappen endrer utseende når skraping pågår */}
<button
onClick={handleRunScrapers}
disabled={selectedFacilities.length === 0 || isScraping}
className={`text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all
disabled={selectedFacilities.length === 0 && !isScraping}
className={`text-white px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all whitespace-nowrap
${isScraping
? 'bg-yellow-500 animate-pulse cursor-wait'
? 'bg-yellow-500 animate-pulse cursor-pointer hover:bg-yellow-600'
: 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'
}`}
>
{isScraping ? '🤖 Skraper... Henter live data' : `Kjør valgte skrapere (${selectedFacilities.length})`}
{isScraping ? '🤖 Skraper... Klikk for å avslutte' : `Kjør valgte skrapere (${selectedFacilities.length})`}
</button>
</header>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
{/* Her er den viktige overflow-klassen for selve tabellen */}
<div className="overflow-x-auto pb-4">
<table className="w-full text-left border-collapse min-w-[800px]">
<thead>
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
<th className="pb-6 pl-6 w-10">
<th className="pb-4 pl-4 w-10">
<input
type="checkbox"
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
@ -137,18 +318,18 @@ export default function AdminDashboard() {
onChange={handleSelectAll}
/>
</th>
<th className="pb-6 pr-10">Anlegg</th>
<th className="pb-6">Konfigurasjon</th>
<th className="pb-6">Metode</th>
<th className="pb-6">Siste Sjekk</th>
<th className="pb-6">Banestatus</th>
<th className="pb-6 text-right">Handling</th>
<th className="pb-4 pr-6">Anlegg</th>
<th className="pb-4">Konfigurasjon</th>
<th className="pb-4">Metode</th>
<th className="pb-4">Siste Sjekk</th>
<th className="pb-4">Banestatus</th>
<th className="pb-4 text-right pr-4">Handling</th>
</tr>
</thead>
<tbody className="text-sm font-bold text-[#11280f]">
{facilities.map((f: any) => (
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
<td className="py-8 pl-6 w-10">
<td className="py-6 pl-4 w-10">
<input
type="checkbox"
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
@ -156,22 +337,24 @@ export default function AdminDashboard() {
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
/>
</td>
<td className="py-8 pl-10">
<div className="font-black text-lg">{f.name}</div>
<td className="py-6 pr-6">
<div className="font-black text-base md:text-lg whitespace-nowrap">{f.name}</div>
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
</td>
<td className="py-8">
<div className="text-[11px] text-blue-600 truncate max-w-[200px] mb-1">{f.scrape_status_url}</div>
<div className="text-[10px] font-mono text-gray-300">{f.scrape_status_selector}</div>
<td className="py-6 pr-4">
<div className="text-[10px] text-blue-600 truncate max-w-[150px] mb-1">
{f.scrape_status_url ? f.scrape_status_url : <span className="text-red-400 italic">Mangler URL</span>}
</div>
<div className="text-[9px] font-mono text-gray-300 truncate max-w-[150px]">{f.scrape_status_selector}</div>
</td>
<td className="py-8 pr-4">
<td className="py-6 pr-4">
<ScrapeMethodSelect facility={f} />
</td>
<td className="py-8 text-gray-400 font-mono text-xs">
<td className="py-6 text-gray-400 font-mono text-xs pr-4 whitespace-nowrap">
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
</td>
<td className="py-8">
<div className="flex flex-col gap-2">
<td className="py-6 pr-4">
<div className="flex flex-col gap-1">
{f.course_statuses && f.course_statuses.map((cs: any, idx: number) => {
let badgeColor = "bg-gray-100 text-gray-500";
if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700";
@ -180,10 +363,10 @@ export default function AdminDashboard() {
return (
<div key={idx} className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-widest text-gray-400 truncate max-w-[100px]" title={cs.name}>
<span className="text-[9px] uppercase tracking-widest text-gray-400 truncate max-w-[80px]" title={cs.name}>
{cs.name}
</span>
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase tracking-widest ${badgeColor}`}>
<span className={`px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${badgeColor}`}>
{cs.status || 'UKJENT'}
</span>
</div>
@ -191,8 +374,13 @@ export default function AdminDashboard() {
})}
</div>
</td>
<td className="py-8 text-right">
<button className="bg-gray-100 px-5 py-2.5 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all">Rediger</button>
<td className="py-6 text-right pr-4">
<button
onClick={() => openEditModal(f)}
className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all whitespace-nowrap"
>
Rediger
</button>
</td>
</tr>
))}

View file

@ -30,8 +30,11 @@ export default async function Home() {
return (
<main className="min-h-screen bg-[#f1f7ed]">
<HeroSlider facilities={safeData} />
{/* Wrapper slideren i en div som skjuler den på mobil (hidden) og viser den på PC (md:block) */}
<div className="hidden md:block">
<HeroSlider facilities={safeData} />
</div>
<FacilitySearch initialFacilities={safeData} />
</main>
);
}
}