Er skraping ferdig nå?
This commit is contained in:
parent
78e7d2b12e
commit
726785dfcc
5 changed files with 283 additions and 68 deletions
Binary file not shown.
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 på å 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'])
|
||||
|
||||
|
|
|
|||
|
|
@ -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 på {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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue