3 Commits
22 changed files with 622 additions and 295 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Build & Attach Release
on:
release:
types: [published]
jobs:
build-and-release:
name: Build and Attach ZIP
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies and Build
run: |
npm ci
npm run build
- name: Create ZIP archive
run: |
cd dist
zip -r ../naturvardarna-site.zip .
- name: Attach ZIP to Release
uses: softprops/action-gh-release@v2
with:
name: ${{ github.event.release.tag_name }}
body: ${{ github.event.release.body || 'Automated release of the Naturvärdarna static site.' }}
files: naturvardarna-site.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-39
View File
@@ -1,39 +0,0 @@
stages:
- Build
- Release
variables:
PACKAGE_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/naturvardarna-site/${CI_COMMIT_TAG}/naturvardarna-site.zip"
"Build":
stage: Build
image: node:alpine
rules:
- if: $CI_COMMIT_TAG
script:
- npm ci
- npm run build
- apk add --no-cache zip curl
- cd dist && zip -r ../naturvardarna-site.zip . && cd ..
- 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file naturvardarna-site.zip "${PACKAGE_URL}"'
artifacts:
paths:
- dist/
expire_in: 1 week
"Create Release":
stage: Release
image: registry.gitlab.com/gitlab-org/release-cli:latest
rules:
- if: $CI_COMMIT_TAG
script:
- echo "Creating permanent release for tag $CI_COMMIT_TAG"
release:
name: "Release $CI_COMMIT_TAG"
description: "Automated release of the Naturvärdarna static site."
tag_name: "$CI_COMMIT_TAG"
ref: "$CI_COMMIT_TAG"
assets:
links:
- name: "Compiled Site (ZIP)"
url: "$PACKAGE_URL"
+7 -2
View File
@@ -1,13 +1,18 @@
<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon-simple.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>naturvardarna</title>
<title>Naturvärdarna</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+97 -1
View File
@@ -9,9 +9,11 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"i18next": "^25.8.20",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.8",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
@@ -221,6 +223,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -2123,6 +2134,46 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "25.8.20",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.20.tgz",
"integrity": "sha512-xjo9+lbX/P1tQt3xpO2rfJiBppNfUnNIPKgCvNsTKsvTOCro1Qr/geXVg1N47j5ScOSaXAPq8ET93raK3Rr06A==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2812,6 +2863,33 @@
"react": "^19.2.4"
}
},
"node_modules/react-i18next": {
"version": "16.5.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.8.tgz",
"integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3007,7 +3085,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -3089,6 +3167,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
@@ -3166,6 +3253,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2
View File
@@ -11,9 +11,11 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"i18next": "^25.8.20",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.8",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600" viewBox="0 0 5 3"><path d="M0 0h5v3H0z"/><path fill="#D00" d="M0 1h5v2H0z"/><path fill="#FFCE00" d="M0 2h5v1H0z"/></svg>

After

Width:  |  Height:  |  Size: 186 B

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 160 160">
<!-- Generator: Adobe Illustrator 30.1.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 136) -->
<defs>
<style>
.st0 {
fill: #fff;
}
</style>
</defs>
<polygon class="st0" points="114.6 80 143.3 63.4 143.3 43.4 143.3 43.4 126 33.4 97.3 50 97.3 16.8 80 6.9 62.7 16.8 62.7 50 34 33.4 16.7 43.4 16.7 43.4 16.7 63.4 45.4 80 16.7 96.6 16.7 116.6 34 126.6 62.7 109.9 62.7 143.2 80 153.1 97.3 143.2 97.3 109.9 126 126.6 143.3 116.6 143.3 96.6 114.6 80"/>
</svg>

After

Width:  |  Height:  |  Size: 619 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30" width="1200" height="600"><clipPath id="a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="b"><path d="M30 15h30v15zv15H0zH0V0zV0h30z"/></clipPath><g clip-path="url(#a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="m0 0 60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="m0 0 60 30m0-30L0 30" clip-path="url(#b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 8 5"><path fill="#005293" d="M0 0h8v5H0Z"/><path stroke="#fecb00" d="M0 2.5h8M3 0v5"/></svg>

After

Width:  |  Height:  |  Size: 172 B

+142 -232
View File
@@ -1,101 +1,65 @@
// src/App.tsx
import { useState } from 'react';
import { Menu, X, Globe, Trash2, Map, Leaf, AlertTriangle, Info, Tent, Car } from 'lucide-react';
import { ChevronDown, Menu, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import './i18n';
import About from './pages/About';
import Home from './pages/Home';
// --- TYPES & DATA ---
type Tab = 'home' | 'about';
type Language = 'sv' | 'en';
const translations = {
sv: {
nav: { home: 'Hem', about: 'Om oss' },
hero: {
title: 'Vi värnar om Kullaberg',
subtitle: 'Vi är sommarjobbarna som håller naturreservatet rent, säkert och vackert under årets mest hektiska veckor.'
},
whatWeDo: {
title: 'Vårt uppdrag',
cleaningTitle: 'Håller rent',
cleaningDesc: 'Vi städar toaletter, parkeringsplatser, rastplatser och stränder dagligen för att skydda djurlivet och naturen från slitage.',
guidingTitle: 'Guidar besökare',
guidingDesc: 'Vi hjälper turister att hitta rätt, informerar om reservatets unika regler och ser till att allemansrätten respekteras.',
natureTitle: 'Naturvård',
natureDesc: 'Vi underhåller vandringsleder, bekämpar invasiva arter och fyller på ved för att skydda skogen.'
},
importantInfo: {
title: 'Viktig information för besökare',
campingTitle: 'Tältning',
campingDesc: 'Tältning är endast tillåten på angivna platser, exempelvis vid Stora parkeringen. Läs skyltarna noga.',
parkingTitle: 'Parkering',
parkingDesc: 'Parkering är gratis så länge du står inom markerade områden. Blockera aldrig räddningsvägar.',
fireTitle: 'Eldning',
fireDesc: 'Använd endast vår utlagda ved vid de fasta grillplatserna. Bryt aldrig grenar från levande eller döda träd.'
},
about: {
title: 'Om Naturvärdarna',
p1: 'Vi är ett gäng engagerade ungdomar som fått det ärofyllda uppdraget att arbeta som Naturvärdar på vackra Kullaberg under sommarmånaderna, ledda av våra projektledare.',
p2: 'Vårt mål är att göra Kullaberg till en bättre plats för besökare och minska det negativa slitaget på naturen. När trycket från turister och besökare är som allra störst, är det vi som rycker ut.',
p3: 'Våra arbetsuppgifter är väldigt varierande. En vanlig dag börjar med en ordentlig städrunda av faciliteter och parkeringsplatser. Därefter arbetar vi med förvaltning som att röja stigar, bygga räcken, olja in träskyltar och bekämpa invasiva växter. Vi plockar skräp längs kusterna och står även som Naturvärdar ute i fält för att svara på era frågor.'
},
footer: 'Naturvärdarna Kullaberg. Ta hand om naturen.'
},
en: {
nav: { home: 'Home', about: 'About Us' },
hero: {
title: 'Preserving Kullaberg',
subtitle: 'We are the summer workers keeping the nature reserve clean, safe, and beautiful during the busiest weeks of the year.'
},
whatWeDo: {
title: 'Our Mission',
cleaningTitle: 'Keeping it Clean',
cleaningDesc: 'We clean toilets, parking lots, rest areas, and beaches daily to protect wildlife and nature from wear and tear.',
guidingTitle: 'Guiding Visitors',
guidingDesc: 'We help tourists navigate, inform them about the reserve\'s unique rules, and ensure the Right of Public Access is respected.',
natureTitle: 'Nature Conservation',
natureDesc: 'We maintain hiking trails, combat invasive species, and restock firewood to protect the forest.'
},
importantInfo: {
title: 'Important Visitor Information',
campingTitle: 'Camping',
campingDesc: 'Tents are only allowed in designated areas, such as the Main Parking lot. Please read the signs carefully.',
parkingTitle: 'Parking',
parkingDesc: 'Parking is free as long as you park within marked areas. Never block emergency routes.',
fireTitle: 'Fires',
fireDesc: 'Only use the provided firewood at designated barbecue areas. Never break branches from living or dead trees.'
},
about: {
title: 'About the Nature Wardens',
p1: 'We are a group of dedicated youth who have been given the honorable task of working as Nature Wardens at the beautiful Kullaberg during the summer months, led by our project managers.',
p2: 'Our goal is to make Kullaberg a better place for visitors and to reduce negative impact on nature. When the pressure from tourists is at its highest, we are the ones who step in.',
p3: 'Our daily tasks are highly varied. A normal day begins with a thorough cleaning of facilities and parking areas. After that, we do maintenance work such as clearing trails, building fences, oiling wooden signs, and combating invasive species. We pick up trash along the coasts and act as Nature Wardens in the field to answer your questions.'
},
footer: 'Kullaberg Nature Wardens. Take care of nature.'
}
};
// --- DATA ---
const LANGUAGES = [
{ code: 'sv', label: 'SV', name: 'Svenska', flag: '/se.svg' },
{ code: 'en', label: 'EN', name: 'English', flag: '/gb.svg' },
{ code: 'de', label: 'DE', name: 'Deutsch', flag: '/de.svg' }
];
// --- COMPONENTS ---
const Navbar = ({ activeTab, setActiveTab, lang, setLang }: any) => {
const Navbar = ({ activeTab, setActiveTab }: { activeTab: Tab, setActiveTab: (t: Tab) => void }) => {
const [isOpen, setIsOpen] = useState(false);
const t = translations[lang as Language].nav;
const [isLangOpen, setIsLangOpen] = useState(false);
const [isMobileLangOpen, setIsMobileLangOpen] = useState(false);
const { t, i18n } = useTranslation();
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsLangOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleNav = (tab: Tab) => {
setActiveTab(tab);
setIsOpen(false);
setIsMobileLangOpen(false);
};
const selectLanguage = (code: string) => {
i18n.changeLanguage(code);
localStorage.setItem('kullaberg_lang', code);
setIsLangOpen(false);
setIsOpen(false);
setIsMobileLangOpen(false);
};
const currentLang = LANGUAGES.find(l => l.code === i18n.language) || LANGUAGES[0];
return (
<nav className="bg-ebony text-eggshell shadow-lg sticky top-0 z-50">
<nav className="bg-ebony text-white shadow-lg sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-20">
{/* Logo & Brand */}
<div
className="flex items-center gap-3 cursor-pointer"
onClick={() => handleNav('home')}
>
<img src="/favicon.svg" alt="Naturvärdarna Logo" className="h-12 w-12" />
<span className="text-2xl font-bold tracking-wide hidden sm:block">Naturvärdarna</span>
<div className="flex items-center gap-3 cursor-pointer" onClick={() => handleNav('home')}>
<img src="/favicon-simple.svg" alt="Naturvärdarna Logo" className="h-12 w-12" />
<span className="text-3xl font-bold tracking-wide hidden sm:block">Naturvärdarna</span>
</div>
{/* Desktop Nav */}
@@ -104,55 +68,89 @@ const Navbar = ({ activeTab, setActiveTab, lang, setLang }: any) => {
onClick={() => handleNav('home')}
className={`hover:text-gold transition-colors cursor-pointer pb-1 ${activeTab === 'home' ? 'border-b-2 border-gold text-gold' : ''}`}
>
{t.home}
{t('nav.home')}
</button>
<button
onClick={() => handleNav('about')}
className={`hover:text-gold transition-colors cursor-pointer pb-1 ${activeTab === 'about' ? 'border-b-2 border-gold text-gold' : ''}`}
>
{t.about}
{t('nav.about')}
</button>
{/* Desktop Language Dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setLang(lang === 'sv' ? 'en' : 'sv')}
className="flex items-center gap-2 bg-slate-teal hover:bg-seafoam px-4 py-2 rounded-full transition-colors cursor-pointer"
onClick={() => setIsLangOpen(!isLangOpen)}
className="flex items-center gap-2 bg-slate-teal hover:bg-seafoam px-4 py-2 rounded-md transition-colors cursor-pointer font-bold select-none"
>
<Globe size={18} />
<span>{lang === 'sv' ? 'EN' : 'SV'}</span>
<img src={currentLang.flag} alt="Flag" className="w-5 h-auto rounded-xs" />
<span>{currentLang.label}</span>
<ChevronDown size={18} className={`transition-transform duration-200 ${isLangOpen ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown Menu */}
{isLangOpen && (
<div className="absolute right-0 mt-2 w-40 bg-white rounded-lg shadow-xl py-2 border border-gray-100 overflow-hidden z-50 animate-fade-in">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => selectLanguage(lang.code)}
className={`w-full text-left px-4 py-2.5 flex items-center gap-3 hover:bg-eggshell transition-colors cursor-pointer ${currentLang.code === lang.code ? 'bg-eggshell/50 font-bold text-slate-teal' : 'text-gray-700'}`}
>
<img src={lang.flag} alt="Flag" className="w-5 h-auto rounded-xs" />
{lang.name}
</button>
))}
</div>
)}
</div>
</div>
{/* Mobile Menu Button */}
{/* Mobile Menu Toggle Button */}
<div className="md:hidden flex items-center">
<button
onClick={() => setLang(lang === 'sv' ? 'en' : 'sv')}
className="flex items-center gap-1 bg-slate-teal hover:bg-seafoam px-3 py-1.5 rounded-full transition-colors cursor-pointer mr-4 text-sm"
>
<Globe size={16} />
<span>{lang === 'sv' ? 'EN' : 'SV'}</span>
</button>
<button onClick={() => setIsOpen(!isOpen)} className="text-eggshell hover:text-gold cursor-pointer">
<button onClick={() => setIsOpen(!isOpen)} className="text-eggshell hover:text-gold cursor-pointer p-2">
{isOpen ? <X size={28} /> : <Menu size={28} />}
</button>
</div>
</div>
</div>
{/* Mobile Nav Dropdown */}
{/* Mobile Nav Dropdown (Now using absolute positioning for overlay) */}
{isOpen && (
<div className="md:hidden bg-moss border-t border-ebony">
<div className="md:hidden absolute top-full left-0 w-full bg-moss border-t border-ebony shadow-2xl animate-fade-in z-50">
<div className="px-2 pt-2 pb-4 space-y-1 sm:px-3">
<button
onClick={() => handleNav('home')}
className="block w-full text-left px-3 py-4 text-eggshell font-medium border-b border-ebony/30"
>
{t.home}
<button onClick={() => handleNav('home')} className="block w-full text-left px-3 py-4 text-eggshell font-medium border-b border-ebony/30 cursor-pointer">
{t('nav.home')}
</button>
<button
onClick={() => handleNav('about')}
className="block w-full text-left px-3 py-4 text-eggshell font-medium"
>
{t.about}
<button onClick={() => handleNav('about')} className="block w-full text-left px-3 py-4 text-eggshell font-medium border-b border-ebony/30 cursor-pointer">
{t('nav.about')}
</button>
{/* Mobile Language Selector (Accordion) */}
<div className="px-3 py-4">
<button
onClick={() => setIsMobileLangOpen(!isMobileLangOpen)}
className="w-full flex justify-between items-center text-sm text-eggshell/80 uppercase tracking-wider font-bold cursor-pointer transition-colors hover:text-white"
>
<span>{t('nav.languageSelector')}</span>
<ChevronDown size={20} className={`transition-transform duration-200 ${isMobileLangOpen ? 'rotate-180' : ''}`} />
</button>
{isMobileLangOpen && (
<div className="flex flex-col gap-2 mt-4 animate-fade-in">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => selectLanguage(lang.code)}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors cursor-pointer ${currentLang.code === lang.code ? 'bg-slate-teal text-white font-bold' : 'bg-ebony/20 text-eggshell hover:bg-ebony/40'}`}
>
<img src={lang.flag} alt="Flag" className="w-6 h-auto rounded-xs" />
{lang.name}
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
@@ -160,147 +158,59 @@ const Navbar = ({ activeTab, setActiveTab, lang, setLang }: any) => {
);
};
const Home = ({ lang }: { lang: Language }) => {
const t = translations[lang];
const Footer = () => {
const { t } = useTranslation();
return (
<div className="animate-fade-in pb-12">
{/* Hero Section */}
<div
className="h-[60vh] min-h-100 bg-cover bg-center flex items-center justify-center relative"
style={{ backgroundImage: "url('/Kullaberg © Johan Hammar.jpg')" }}
>
<div className="absolute inset-0 bg-ebony/30"></div>
<div className="relative z-10 text-center text-eggshell px-4 max-w-4xl mx-auto">
<h1 className="text-4xl md:text-6xl font-bold mb-6 drop-shadow-lg leading-tight">{t.hero.title}</h1>
<p className="text-lg md:text-2xl drop-shadow-md text-eggshell/90">
{t.hero.subtitle}
<footer className="bg-ebony text-white py-5 md:py-8 text-center mt-auto border-t-4 border-goldenrod">
<div className="max-w-7xl mx-auto px-4 flex flex-col md:flex-row justify-between items-center gap-3 md:gap-4">
{/* Left side: Logo and Copyright */}
<div className="flex items-center gap-2 md:gap-3">
<img src="/favicon-simple.svg" alt="Logo" className="h-6 w-6 md:h-8 md:w-8 opacity-80" />
<p className="text-sm md:text-base font-medium tracking-wide">
© {new Date().getFullYear()} {t('footerCore')} <span className="hidden md:inline">{t('footerSlogan')}</span>
</p>
</div>
</div>
{/* What We Do Section */}
<div className="max-w-7xl mx-auto py-16 px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-ebony mb-12 text-center flex items-center justify-center gap-3">
<Info className="text-goldenrod" size={32} />
{t.whatWeDo.title}
</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="bg-white p-8 rounded-2xl shadow-sm border-t-4 border-seafoam hover:shadow-md transition-shadow">
<Trash2 className="text-seafoam mb-4" size={40} />
<h3 className="text-2xl font-bold text-slate-teal mb-3">{t.whatWeDo.cleaningTitle}</h3>
<p className="text-gray-700 leading-relaxed">{t.whatWeDo.cleaningDesc}</p>
</div>
<div className="bg-white p-8 rounded-2xl shadow-sm border-t-4 border-seafoam hover:shadow-md transition-shadow">
<Map className="text-seafoam mb-4" size={40} />
<h3 className="text-2xl font-bold text-slate-teal mb-3">{t.whatWeDo.guidingTitle}</h3>
<p className="text-gray-700 leading-relaxed">{t.whatWeDo.guidingDesc}</p>
</div>
<div className="bg-white p-8 rounded-2xl shadow-sm border-t-4 border-seafoam hover:shadow-md transition-shadow">
<Leaf className="text-seafoam mb-4" size={40} />
<h3 className="text-2xl font-bold text-slate-teal mb-3">{t.whatWeDo.natureTitle}</h3>
<p className="text-gray-700 leading-relaxed">{t.whatWeDo.natureDesc}</p>
</div>
</div>
</div>
{/* Important Info Section */}
<div className="bg-moss/20 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-ebony mb-12 text-center flex items-center justify-center gap-3">
<AlertTriangle className="text-emergency" size={32} />
{t.importantInfo.title}
</h2>
<div className="grid md:grid-cols-3 gap-6">
<div className="bg-eggshell p-6 rounded-xl border border-moss/30 flex items-start gap-4">
<Tent className="text-goldenrod shrink-0 mt-1" size={28} />
<div>
<h4 className="font-bold text-ebony text-lg mb-1">{t.importantInfo.campingTitle}</h4>
<p className="text-gray-800 text-sm leading-relaxed">{t.importantInfo.campingDesc}</p>
</div>
</div>
<div className="bg-eggshell p-6 rounded-xl border border-moss/30 flex items-start gap-4">
<Car className="text-goldenrod shrink-0 mt-1" size={28} />
<div>
<h4 className="font-bold text-ebony text-lg mb-1">{t.importantInfo.parkingTitle}</h4>
<p className="text-gray-800 text-sm leading-relaxed">{t.importantInfo.parkingDesc}</p>
</div>
</div>
<div className="bg-eggshell p-6 rounded-xl border border-moss/30 flex items-start gap-4">
<AlertTriangle className="text-goldenrod shrink-0 mt-1" size={28} />
<div>
<h4 className="font-bold text-ebony text-lg mb-1">{t.importantInfo.fireTitle}</h4>
<p className="text-gray-800 text-sm leading-relaxed">{t.importantInfo.fireDesc}</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
const About = ({ lang }: { lang: Language }) => {
const t = translations[lang].about;
return (
<div className="max-w-5xl mx-auto py-16 px-4 sm:px-6 lg:px-8 animate-fade-in">
<div className="flex flex-col md:flex-row gap-12 items-center">
<div className="w-full md:w-1/2">
<h1 className="text-4xl md:text-5xl font-bold text-ebony mb-6 border-b-4 border-gold pb-4 inline-block">{t.title}</h1>
<div className="prose prose-lg text-gray-800 max-w-none space-y-6">
<p className="text-lg leading-relaxed">{t.p1}</p>
<p className="text-lg leading-relaxed">{t.p2}</p>
<p className="text-lg leading-relaxed">{t.p3}</p>
</div>
</div>
<div className="w-full md:w-1/2">
<img
src="/about_us.jpg"
alt="Nature Wardens working"
className="w-full h-auto rounded-2xl shadow-xl border-4 border-eggshell rotate-1 hover:rotate-0 transition-transform duration-300"
/>
</div>
</div>
</div>
);
};
const Footer = ({ lang }: { lang: Language }) => (
<footer className="bg-ebony text-eggshell py-8 text-center mt-auto border-t-4 border-goldenrod">
<div className="max-w-7xl mx-auto px-4 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-2">
<img src="/favicon.svg" alt="Logo" className="h-8 w-8 opacity-80" />
<p className="font-medium tracking-wide">© {new Date().getFullYear()} {translations[lang].footer}</p>
</div>
<div className="text-sm text-eggshell/70">
{/* Right side: Reserve Name */}
<div className="text-xs md:text-sm text-eggshell/70">
Kullabergs Naturreservat
</div>
</div>
</footer>
);
// --- MAIN COMPONENT ---
export default function App() {
const [activeTab, setActiveTab] = useState<Tab>('home');
const [lang, setLang] = useState<Language>('sv');
const renderContent = () => {
switch (activeTab) {
case 'home': return <Home lang={lang} />;
case 'about': return <About lang={lang} />;
default: return <Home lang={lang} />;
}
};
export default function App() {
const [activeTab, setActiveTab] = useState<Tab>(() => {
const hash = window.location.hash.replace('#', '');
return hash === 'about' ? 'about' : 'home';
});
useEffect(() => {
window.location.hash = activeTab;
window.scrollTo(0, 0);
}, [activeTab]);
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.replace('#', '');
setActiveTab(hash === 'about' ? 'about' : 'home');
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
return (
<div className="min-h-screen flex flex-col font-sans bg-eggshell selection:bg-gold selection:text-ebony">
<Navbar activeTab={activeTab} setActiveTab={setActiveTab} lang={lang} setLang={setLang} />
<div className="min-h-screen flex flex-col font-sans bg-white selection:bg-gold selection:text-ebony">
<Navbar activeTab={activeTab} setActiveTab={setActiveTab} />
<main className="grow">
{renderContent()}
{activeTab === 'home' ? <Home /> : <About />}
</main>
<Footer lang={lang} />
<Footer />
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
// src/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
const resources = {
sv: {
translation: {
nav: { home: 'Hem', about: 'Om oss', languageSelector: 'Språk' },
hero: {
title: 'Vi värnar om Kullaberg',
subtitle: 'Vi är naturvärdarna som håller naturreservatet rent, säkert och vackert under sommarsäsongen.'
},
whatWeDo: {
title: 'Vårt uppdrag',
cleaningTitle: 'Håller rent',
cleaningDesc: 'Vi städar toaletter, parkeringsplatser, rastplatser och stränder dagligen för att skydda djurlivet och naturen från slitage.',
guidingTitle: 'Guidar besökare',
guidingDesc: 'Vi hjälper turister att hitta rätt, informerar om reservatets unika regler och ser till att naturen respekteras.',
natureTitle: 'Naturvård',
natureDesc: 'Vi underhåller vandringsleder, bekämpar invasiva arter och fyller på ved för att skydda skogen.'
},
importantInfo: {
title: 'Viktig information för besökare',
campingTitle: 'Tältning',
campingDesc: 'Tältning är endast tillåten på angivna platser, exempelvis vid Stora parkeringen. Läs skyltarna noga.',
parkingTitle: 'Parkering',
parkingDesc: 'Parkering är gratis så länge du står inom markerade områden. Blockera aldrig räddningsvägar.',
fireTitle: 'Eldning',
fireDesc: 'Använd endast vår utlagda ved vid de fasta grillplatserna. Bryt aldrig grenar från levande eller döda träd.'
},
about: {
title: 'Vilka är vi?',
intro: 'Vi är ett gäng sommarjobbande ungdomar som fått det ärofyllda uppdraget att arbeta som Naturvärdar på vackra Kullaberg under sommarmånaderna',
missionTitle: 'Mycket mer än bara städning',
missionText: 'Vårt mål är att göra Kullaberg till en bättre plats för besökare och minska det negativa slitaget på naturen. När trycket från turister och besökare är som allra störst, är det vi som rycker ut. Utöver vårt dagliga underhåll utför vi specialuppdrag: vi bygger staket, röjer nya vandringsleder, oljar in träskyltar eller reparerar trappor och bänkar',
pride: 'Kullaberg är en fantastisk plats, och vi är otroligt stolta över att få hjälpa till att hålla reservatet i absolut toppskick!',
galleryTitle: 'Bilder från fältet'
},
footerCore: 'Naturvärdarna Kullaberg.',
footerSlogan: 'Vi tar hand om naturen.'
}
},
en: {
translation: {
nav: { home: 'Home', about: 'About Us', languageSelector: 'Language' },
hero: {
title: 'Preserving Kullaberg',
subtitle: 'We are the nature guides keeping the nature reserve clean, safe, and beautiful during the summer season.'
},
whatWeDo: {
title: 'Our Mission',
cleaningTitle: 'Keeping it Clean',
cleaningDesc: 'We clean toilets, parking lots, rest areas, and beaches daily to protect wildlife and nature from wear and tear.',
guidingTitle: 'Guiding Visitors',
guidingDesc: 'We help tourists navigate, inform them about the reserve\'s unique rules, and ensure the nature is respected.',
natureTitle: 'Nature Conservation',
natureDesc: 'We maintain hiking trails, combat invasive species, and restock firewood to protect the forest.'
},
importantInfo: {
title: 'Important Visitor Information',
campingTitle: 'Camping',
campingDesc: 'Tents are only allowed in designated areas, such as the Main Parking lot. Please read the signs carefully.',
parkingTitle: 'Parking',
parkingDesc: 'Parking is free as long as you park within marked areas. Never block emergency routes.',
fireTitle: 'Fires',
fireDesc: 'Only use the provided firewood at designated barbecue areas. Never break branches from living or dead trees.'
},
about: {
title: 'Who are we?',
intro: 'We are a group of summer working youth who have been given the honorable task of working as Nature Guides at the beautiful Kullaberg during the summer months.',
missionTitle: 'Much more than just cleaning',
missionText: 'Our goal is to make Kullaberg a better place for visitors and to reduce negative impact on nature. When the pressure from tourists is at its highest, we step in. Beyond our daily maintenance, we carry out special missions: building fences, clearing new hiking trails, oiling wooden signs, or repairing stairs and benches',
pride: 'Kullaberg is an amazing place, and we are incredibly proud to help keep the reserve in pristine condition!',
galleryTitle: 'Pictures from the field'
},
footerCore: 'Kullaberg Nature Guides.',
footerSlogan: 'We take care of nature.'
}
},
de: {
translation: {
nav: { home: 'Startseite', about: 'Über uns', languageSelector: 'Sprache' },
hero: {
title: 'Wir schützen den Kullaberg',
subtitle: 'Wir sind die Naturwarte, die das Naturschutzgebiet während der Sommersaison sauber, sicher und schön halten.'
},
whatWeDo: {
title: 'Unsere Mission',
cleaningTitle: 'Sauberkeit',
cleaningDesc: 'Wir reinigen täglich Toiletten, Parkplätze, Rastplätze und Strände, um die Tierwelt und die Natur vor Abnutzung zu schützen.',
guidingTitle: 'Besucherbetreuung',
guidingDesc: 'Wir helfen Touristen bei der Orientierung, informieren über die einzigartigen Regeln des Reservats und sorgen dafür, dass die Natur respektiert wird.',
natureTitle: 'Naturschutz',
natureDesc: 'Wir pflegen Wanderwege, bekämpfen invasive Arten und füllen Brennholz auf, um den Wald zu schützen.'
},
importantInfo: {
title: 'Wichtige Informationen für Besucher',
campingTitle: 'Zelten',
campingDesc: 'Zelten ist nur an ausgewiesenen Plätzen, wie zum Beispiel am Stora parkeringen, erlaubt. Bitte lesen Sie die Schilder sorgfältig durch.',
parkingTitle: 'Parken',
parkingDesc: 'Das Parken ist kostenlos, solange Sie innerhalb der markierten Bereiche stehen. Blockieren Sie niemals Rettungswege.',
fireTitle: 'Feuer',
fireDesc: 'Verwenden Sie nur unser bereitgestelltes Brennholz an den festen Grillplätzen. Brechen Sie niemals Äste von lebenden oder toten Bäumen ab.'
},
about: {
title: 'Wer sind wir?',
intro: 'Wir sind eine Gruppe von Jugendlichen mit einem Sommerjob, die die ehrenvolle Aufgabe erhalten haben, während der Sommermonate als Naturwarte auf dem wunderschönen Kullaberg zu arbeiten.',
missionTitle: 'Viel mehr als nur Aufräumen',
missionText: 'Unser Ziel ist es, den Kullaberg zu einem besseren Ort für Besucher zu machen und die negativen Auswirkungen auf die Natur zu reduzieren. Wenn der Ansturm von Touristen und Besuchern am größten ist, greifen wir ein. Neben unserer täglichen Instandhaltung führen wir Sonderaufgaben durch: Wir bauen Zäune, räumen neue Wanderwege frei, ölen Holzschilder oder reparieren Treppen und Bänke.',
pride: 'Der Kullaberg ist ein fantastischer Ort, und wir sind unglaublich stolz darauf, dabei zu helfen, das Reservat in einem absoluten Top-Zustand zu halten!',
galleryTitle: 'Bilder aus dem Feld'
},
footerCore: 'Naturwarte Kullaberg.',
footerSlogan: 'Wir kümmern uns um die Natur.'
}
}
};
const savedLanguage = localStorage.getItem('kullaberg_lang') || 'sv';
i18n
.use(initReactI18next)
.init({
resources,
lng: savedLanguage,
fallbackLng: 'en',
interpolation: { escapeValue: false }
});
export default i18n;
-4
View File
@@ -11,7 +11,3 @@
--color-slate-teal: #456466;
--color-seafoam: #5B8C8A;
}
body {
background-color: var(--color-eggshell);
}
+75
View File
@@ -0,0 +1,75 @@
// src/pages/About.tsx
import { useTranslation } from 'react-i18next';
export default function About() {
const { t } = useTranslation();
return (
<div className="max-w-7xl mx-auto py-16 px-4 sm:px-6 lg:px-8 animate-fade-in">
{/* Top Section: Text and Hero Image */}
<div className="grid lg:grid-cols-2 gap-12 items-center mb-20">
<div>
<h1 className="text-4xl md:text-5xl font-bold text-ebony mb-6 border-b-4 border-gold pb-4 inline-block">
{t('about.title')}
</h1>
<p className="text-xl text-slate-teal font-medium mb-6 leading-relaxed">
{t('about.intro')}
</p>
<div className="bg-eggshell p-6 rounded-2xl border-l-4 border-moss shadow-sm">
<h3 className="text-2xl font-bold text-ebony mb-3">{t('about.missionTitle')}</h3>
<p className="text-gray-800 leading-relaxed mb-4">
{t('about.missionText')}
</p>
<p className="text-gray-800 font-bold italic">
{t('about.pride')}
</p>
</div>
</div>
{/* Main Feature Image (Group Photo Placeholder) */}
<div className="relative">
<div className="absolute inset-0 bg-gold rounded-3xl translate-x-4 translate-y-4 opacity-50"></div>
<img
src="/about_us_1.webp"
alt="Naturvärdarna Team"
className="relative z-10 w-full h-125 object-cover rounded-3xl shadow-xl border-4 border-white"
/>
</div>
</div>
{/* Bottom Section: Image Grid Gallery */}
<h2 className="text-3xl font-bold text-ebony mb-8 text-center">{t('about.galleryTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Gallery Image 1 (Rope work placeholder) */}
<div className="group overflow-hidden rounded-2xl shadow-md h-64">
<img
src="/about_us_2.webp"
alt="Working in nature"
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
</div>
{/* Gallery Image 2 (Fence building placeholder) */}
<div className="group overflow-hidden rounded-2xl shadow-md h-64 md:translate-y-8">
<img
src="/about_us_3.webp"
alt="Building and maintenance"
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
</div>
{/* Gallery Image 3 (Kayak/Coast placeholder) */}
<div className="group overflow-hidden rounded-2xl shadow-md h-64">
<img
src="/about_us_4.webp"
alt="Coastal cleaning"
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
</div>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
// src/pages/Home.tsx
import { useTranslation } from 'react-i18next';
import { BrushCleaning, Map, Leaf, AlertTriangle, Info, Tent, SquareParking, FlameKindling } from 'lucide-react';
export default function Home() {
const { t } = useTranslation();
return (
<div className="animate-fade-in pb-12">
{/* Hero Section */}
<div
className="h-[60vh] min-h-100 bg-cover bg-center flex items-center justify-center relative"
style={{ backgroundImage: "url('/kullaberg.webp')" }}
>
<div className="absolute inset-0 bg-linear-to-b from-ebony/50 via-black/40 to-white/30"></div>
<div className="relative z-10 text-center text-white px-4 max-w-4xl mx-auto flex flex-col items-center">
{/* Desktop Logo (Full version) */}
<img
src="/favicon.svg"
alt="Naturvärdarna Logo"
className="block w-48 md:w-64 h-auto mb-8 drop-shadow-[0_4px_8px_rgba(0,0,0,0.6)]"
/>
<h1 className="text-4xl md:text-6xl font-bold mb-6 drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)] leading-tight">{t('hero.title')}</h1>
<p className="text-lg md:text-2xl drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)] text-white font-medium">
{t('hero.subtitle')}
</p>
</div>
</div>
{/* What We Do Section */}
<div className="max-w-7xl mx-auto py-16 px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-ebony mb-12 text-center flex flex-col md:flex-row items-center justify-center gap-3">
<Info className="text-seafoam" size={32} />
<span>{t('whatWeDo.title')}</span>
</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="bg-white p-8 rounded-2xl shadow-sm border-t-4 border-seafoam hover:shadow-md transition-shadow">
<Leaf className="text-seafoam mb-4" size={40} />
<h3 className="text-2xl font-bold text-slate-teal mb-3">{t('whatWeDo.natureTitle')}</h3>
<p className="text-gray-700 leading-relaxed">{t('whatWeDo.natureDesc')}</p>
</div>
<div className="bg-white p-8 rounded-2xl shadow-sm border-t-4 border-seafoam hover:shadow-md transition-shadow">
<Map className="text-seafoam mb-4" size={40} />
<h3 className="text-2xl font-bold text-slate-teal mb-3">{t('whatWeDo.guidingTitle')}</h3>
<p className="text-gray-700 leading-relaxed">{t('whatWeDo.guidingDesc')}</p>
</div>
<div className="bg-white p-8 rounded-2xl shadow-sm border-t-4 border-seafoam hover:shadow-md transition-shadow">
<BrushCleaning className="text-seafoam mb-4" size={40} />
<h3 className="text-2xl font-bold text-slate-teal mb-3">{t('whatWeDo.cleaningTitle')}</h3>
<p className="text-gray-700 leading-relaxed">{t('whatWeDo.cleaningDesc')}</p>
</div>
</div>
</div>
{/* Important Info Section */}
<div className="bg-moss/10 py-16 border-y border-moss/20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-ebony mb-12 text-center flex flex-col md:flex-row items-center justify-center gap-3">
<AlertTriangle className="text-goldenrod" size={32} />
<span>{t('importantInfo.title')}</span>
</h2>
<div className="grid md:grid-cols-3 gap-6">
<div className="bg-eggshell p-6 rounded-xl border border-moss/30 flex items-start gap-4 shadow-sm">
<Tent className="text-goldenrod shrink-0 mt-1" size={28} />
<div>
<h4 className="font-bold text-ebony text-lg mb-1">{t('importantInfo.campingTitle')}</h4>
<p className="text-gray-800 text-sm leading-relaxed">{t('importantInfo.campingDesc')}</p>
</div>
</div>
<div className="bg-eggshell p-6 rounded-xl border border-moss/30 flex items-start gap-4 shadow-sm">
<SquareParking className="text-goldenrod shrink-0 mt-1" size={28} />
<div>
<h4 className="font-bold text-ebony text-lg mb-1">{t('importantInfo.parkingTitle')}</h4>
<p className="text-gray-800 text-sm leading-relaxed">{t('importantInfo.parkingDesc')}</p>
</div>
</div>
<div className="bg-eggshell p-6 rounded-xl border border-moss/30 flex items-start gap-4 shadow-sm">
<FlameKindling className="text-goldenrod shrink-0 mt-1" size={28} />
<div>
<h4 className="font-bold text-ebony text-lg mb-1">{t('importantInfo.fireTitle')}</h4>
<p className="text-gray-800 text-sm leading-relaxed">{t('importantInfo.fireDesc')}</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+9 -6
View File
@@ -1,10 +1,13 @@
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
})
plugins: [
tailwindcss(),
react()
],
});