Initial commit - Til Mika app with polaroid images and scaling fixes
9
.env.example
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||
# AI Studio automatically injects this at runtime from user secrets.
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
.antigravity/
|
||||
.vscode/
|
||||
19
README.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
https://ai.studio/apps/cb46be2f-ccc9-4306-a691-830141498fbb
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Google AI Studio App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1
metadata.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"name":"Til Mika","description":"Et romantisk, interaktivt polaroid-galleri med svevende minner.","requestFramePermissions":[],"majorCapabilities":["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]}
|
||||
2837
package-lock.json
generated
Normal file
36
package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist server.js",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.4.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"localforage": "^1.10.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"vite": "^6.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3"
|
||||
}
|
||||
}
|
||||
500
src/App.tsx
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence, useAnimation } from "motion/react";
|
||||
import {
|
||||
Heart,
|
||||
Star,
|
||||
Sparkles,
|
||||
Music,
|
||||
Camera,
|
||||
Coffee,
|
||||
Sun,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import localforage from "localforage";
|
||||
|
||||
// ==========================================
|
||||
// MINE TEKSTER - GENERELLE KJÆRLIGHETSERKLÆRINGER ❤️
|
||||
// Her har jeg lagt inn masse fine tekster som kan dukke opp på bildene du laster opp.
|
||||
// ==========================================
|
||||
const kjærlighetsTekster = [
|
||||
{
|
||||
overskrift: "Du er min favorittperson",
|
||||
tekst: "Hver eneste dag er jeg takknemlig for at jeg har deg i livet mitt.",
|
||||
ikon: "Heart",
|
||||
},
|
||||
{
|
||||
overskrift: "For alltid oss to",
|
||||
tekst:
|
||||
"Jeg elsker deg mer i dag enn i går, men ikke på langt nær så mye som jeg vil elske deg i morgen.",
|
||||
ikon: "Star",
|
||||
},
|
||||
{
|
||||
overskrift: "Mitt trygge sted",
|
||||
tekst:
|
||||
"Uansett hvor jeg er, så føles det som hjemme så lenge jeg er sammen med deg.",
|
||||
ikon: "Heart",
|
||||
},
|
||||
{
|
||||
overskrift: "Ubeskrivelig glad i deg",
|
||||
tekst: "Ord kan ikke engang begynne å beskrive hvor mye du betyr for meg.",
|
||||
ikon: "Sparkles",
|
||||
},
|
||||
{
|
||||
overskrift: "Du gjør meg hel",
|
||||
tekst: "Sammen med deg er livet rett og slett et fantastisk eventyr.",
|
||||
ikon: "Heart",
|
||||
},
|
||||
{
|
||||
overskrift: "Mitt alt",
|
||||
tekst: "Du er min sjelevenn, min beste venn, og den jeg vil dele alt med.",
|
||||
ikon: "Star",
|
||||
},
|
||||
{
|
||||
overskrift: "Størst av alt",
|
||||
tekst:
|
||||
"Kjærligheten jeg føler for deg vokser seg bare sterkere og sterkere for hver dag som går.",
|
||||
ikon: "Heart",
|
||||
},
|
||||
{
|
||||
overskrift: "Smilet ditt",
|
||||
tekst:
|
||||
"Å se deg smile er det aller fineste jeg vet. Du lyser opp hverdagen min.",
|
||||
ikon: "Sun",
|
||||
},
|
||||
{
|
||||
overskrift: "Perfekt for meg",
|
||||
tekst: "Jeg elsker alt ved deg, akkurat sånn som du er.",
|
||||
ikon: "Heart",
|
||||
},
|
||||
{
|
||||
overskrift: "Gode minner",
|
||||
tekst:
|
||||
"Takk for alle de nydelige øyeblikkene vi har delt så langt. Jeg gleder meg til resten.",
|
||||
ikon: "Camera",
|
||||
},
|
||||
{
|
||||
overskrift: "Min store kjærlighet",
|
||||
tekst:
|
||||
"Jeg visste ikke at det var mulig å elske noen så mye før jeg møtte deg.",
|
||||
ikon: "Heart",
|
||||
},
|
||||
{
|
||||
overskrift: "Sjelen min",
|
||||
tekst: "Det føles som om sjelene våre alltid har kjent hverandre.",
|
||||
ikon: "Sparkles",
|
||||
},
|
||||
{
|
||||
overskrift: "Vår historie",
|
||||
tekst:
|
||||
"Vår kjærlighetshistorie er min absolutte favoritt. Jeg vil skrive hundre kapitler til med deg.",
|
||||
ikon: "Star",
|
||||
},
|
||||
{
|
||||
overskrift: "Lyspunktet mitt",
|
||||
tekst: "Selv på grå dager er du min solskinnshistorie.",
|
||||
ikon: "Sun",
|
||||
},
|
||||
{
|
||||
overskrift: "Forhekset av deg",
|
||||
tekst: "Fra første stund har du hatt hjertet mitt i din hule hånd.",
|
||||
ikon: "Heart",
|
||||
},
|
||||
{
|
||||
overskrift: "Uerstattelig",
|
||||
tekst: "Det finnes ingen i hele verden som er som deg. Du er helt unik.",
|
||||
ikon: "Star",
|
||||
},
|
||||
{
|
||||
overskrift: "Latteren din",
|
||||
tekst:
|
||||
"Din latter er musikk i mine ører. Du gjør livet mitt utrolig mye gøyere.",
|
||||
ikon: "Music",
|
||||
},
|
||||
{
|
||||
overskrift: "God morgen",
|
||||
tekst: "Det beste med å våkne, er å vite at jeg har deg i livet mitt.",
|
||||
ikon: "Coffee",
|
||||
},
|
||||
{
|
||||
overskrift: "Eventyret",
|
||||
tekst: "Livet med deg er en reise jeg aldri vil at skal ta slutt.",
|
||||
ikon: "Camera",
|
||||
},
|
||||
{
|
||||
overskrift: "Evig din",
|
||||
tekst: "Hjertet mitt er ditt, nå og for alltid.",
|
||||
ikon: "Heart",
|
||||
},
|
||||
];
|
||||
|
||||
// Finn alle bilder i /src/bilder automatisk (uansett navn)
|
||||
const bildeModuler = import.meta.glob(
|
||||
"/src/bilder/*.{png,jpg,jpeg,gif,webp,JPG,PNG,JPEG}",
|
||||
{ eager: true, import: "default" },
|
||||
);
|
||||
const opplastedeBilderUrl = Object.values(bildeModuler) as string[];
|
||||
|
||||
// Bruker Unsplash som fallback hvis ingen bilder er lastet opp enda
|
||||
const standardBilder = [
|
||||
"https://images.unsplash.com/photo-1518199266791-5375a83190b7?w=500&q=80",
|
||||
"https://images.unsplash.com/photo-1522673607200-164d1b6ce486?w=500&q=80",
|
||||
"https://images.unsplash.com/photo-1494774157365-9e04c6720e47?w=500&q=80",
|
||||
"https://images.unsplash.com/photo-1516589178581-6cd7833ae3b2?w=500&q=80",
|
||||
"https://images.unsplash.com/photo-1500917293891-ef795e70e1f6?w=500&q=80",
|
||||
];
|
||||
|
||||
// ==========================================
|
||||
|
||||
const IconMap: Record<string, React.ElementType> = {
|
||||
Heart,
|
||||
Star,
|
||||
Sparkles,
|
||||
Music,
|
||||
Camera,
|
||||
Coffee,
|
||||
Sun,
|
||||
};
|
||||
|
||||
// --- Komponent for de flyvende partiklene (hjerter og stjerner) i bakgrunnen ---
|
||||
const Particles = () => {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
useEffect(() => setIsClient(true), []);
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
const particles = Array.from({ length: 30 });
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 overflow-hidden pointer-events-none">
|
||||
{particles.map((_, i) => {
|
||||
const isHeart = Math.random() > 0.4;
|
||||
const size = Math.random() * 15 + 10;
|
||||
const startX = Math.random() * 100; // vw
|
||||
const duration = Math.random() * 15 + 15;
|
||||
const delay = Math.random() * 15;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute text-pink-400/30"
|
||||
initial={{ y: "110vh", x: `${startX}vw`, opacity: 0, scale: 0 }}
|
||||
animate={{
|
||||
y: "-10vh",
|
||||
opacity: [0, 1, 1, 0],
|
||||
scale: [0, 1, 1, 0.5],
|
||||
rotate: isHeart ? [-20, 20, -20] : [0, 360],
|
||||
}}
|
||||
transition={{
|
||||
duration: duration,
|
||||
repeat: Infinity,
|
||||
delay: delay,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{isHeart ? (
|
||||
<Heart fill="currentColor" size={size} />
|
||||
) : (
|
||||
<Sparkles size={size} />
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Komponent for et enkelt polaroid-kort ---
|
||||
const FloatingCard = ({
|
||||
bilde,
|
||||
isSelected,
|
||||
onSelect,
|
||||
index,
|
||||
total,
|
||||
}: {
|
||||
bilde: any;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
index: number;
|
||||
total: number;
|
||||
}) => {
|
||||
const controls = useAnimation();
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
const Icon = IconMap[bilde.ikon] || Heart;
|
||||
|
||||
const [initialPos] = useState(() => {
|
||||
const isMobile = window.innerWidth < 600;
|
||||
|
||||
// Beregn en grid-posisjon for å forhindre overlapping
|
||||
const cols = Math.ceil(Math.sqrt(total));
|
||||
const rows = Math.ceil(total / cols);
|
||||
const col = index % cols;
|
||||
const row = Math.floor(index / cols);
|
||||
|
||||
// Bruk hele skjermens høyde for å spre dem jevnere utover
|
||||
const startTop = 5;
|
||||
const availableHeight = 85;
|
||||
|
||||
const cellWidth = 100 / cols;
|
||||
const cellHeight = availableHeight / rows;
|
||||
|
||||
// Plasser sentrert i sin "celle" med litt tilfeldig avvik
|
||||
const leftPercent = (col * cellWidth) + (cellWidth / 2) + (Math.random() * (cellWidth * 0.4) - cellWidth * 0.2);
|
||||
const topPercent = startTop + (row * cellHeight) + (cellHeight / 2) + (Math.random() * (cellHeight * 0.4) - cellHeight * 0.2);
|
||||
|
||||
// Hvis det er mange bilder, gjør dem mindre
|
||||
const scaleFactor = Math.max(0.4, 1 - (total * 0.02));
|
||||
const baseScale = (isMobile ? 0.6 : 0.8) * scaleFactor;
|
||||
|
||||
return {
|
||||
leftPercent: Math.max(5, Math.min(85, leftPercent)), // Hold innenfor skjermen
|
||||
topPercent: Math.max(5, Math.min(85, topPercent)), // Lar dem gå helt opp/ned
|
||||
rotate: Math.random() * 40 - 20,
|
||||
baseScale: baseScale,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSelected) {
|
||||
setIsFlipped(false);
|
||||
const points = 5;
|
||||
const x = [0];
|
||||
const y = [0];
|
||||
const rotate = [initialPos.rotate];
|
||||
|
||||
for (let i = 1; i < points; i++) {
|
||||
x.push(Math.random() * 20 - 10);
|
||||
y.push(Math.random() * 20 - 10);
|
||||
rotate.push(initialPos.rotate + (Math.random() * 6 - 3));
|
||||
}
|
||||
x.push(0);
|
||||
y.push(0);
|
||||
rotate.push(initialPos.rotate);
|
||||
|
||||
const sequence = async () => {
|
||||
// Raskt tilbake til opprinnelig posisjon
|
||||
await controls.start({
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotate: initialPos.rotate,
|
||||
scale: initialPos.baseScale,
|
||||
rotateY: 0,
|
||||
boxShadow: "0px 10px 25px rgba(0,0,0,0.3)",
|
||||
transition: { type: "spring", stiffness: 250, damping: 25 },
|
||||
});
|
||||
|
||||
// Begynn å sveve forsiktig rundt igjen
|
||||
controls.start({
|
||||
x,
|
||||
y,
|
||||
rotate,
|
||||
transition: {
|
||||
duration: Math.random() * 20 + 30,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
},
|
||||
});
|
||||
};
|
||||
sequence();
|
||||
} else {
|
||||
const elLeft = (initialPos.leftPercent / 100) * window.innerWidth;
|
||||
const elTop = (initialPos.topPercent / 100) * window.innerHeight;
|
||||
|
||||
const centerX = window.innerWidth / 2 - 90; // 180px / 2
|
||||
const centerY = window.innerHeight / 2 - 110; // 220px / 2
|
||||
|
||||
const deltaX = centerX - elLeft;
|
||||
const deltaY = centerY - elTop;
|
||||
|
||||
const sequence = async () => {
|
||||
await controls.start({
|
||||
x: deltaX,
|
||||
y: deltaY,
|
||||
rotate: 0,
|
||||
scale: window.innerWidth < 600 ? 1.4 : 1.8,
|
||||
boxShadow: "0px 20px 40px rgba(0,0,0,0.8)",
|
||||
transition: { type: "spring", stiffness: 250, damping: 25 },
|
||||
});
|
||||
};
|
||||
sequence();
|
||||
}
|
||||
}, [isSelected, controls, initialPos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
controls.start({
|
||||
rotateY: isFlipped ? 180 : 0,
|
||||
transition: { type: "spring", stiffness: 200, damping: 20 },
|
||||
});
|
||||
}
|
||||
}, [isFlipped, isSelected, controls]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute w-[180px] h-[220px] cursor-pointer [transform-style:preserve-3d]"
|
||||
// Use explicit zIndex via style to ensure it overrides everything else when selected
|
||||
style={{
|
||||
left: `${initialPos.leftPercent}%`,
|
||||
top: `${initialPos.topPercent}%`,
|
||||
zIndex: isSelected ? 50 : 20,
|
||||
}}
|
||||
initial={{ x: 0, y: 0, rotate: initialPos.rotate, scale: initialPos.baseScale }}
|
||||
animate={controls}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isSelected) {
|
||||
setIsFlipped(!isFlipped);
|
||||
} else {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
whileHover={!isSelected ? { scale: initialPos.baseScale + 0.1 } : {}}
|
||||
>
|
||||
{/* Fronten av polaroid-kortet */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[#f8f8f8] p-[12px] pb-[40px] rounded-sm flex flex-col shadow-xl"
|
||||
style={{ backfaceVisibility: "hidden" }}
|
||||
>
|
||||
<div className="w-full h-[140px] bg-gradient-to-tr from-[#ff9a9e] to-[#fad0c4] overflow-hidden relative flex items-center justify-center rounded-sm">
|
||||
<img
|
||||
src={bilde.url}
|
||||
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
|
||||
alt={bilde.overskrift}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[40px] flex items-center justify-center font-serif text-gray-800 text-sm font-bold pointer-events-none">
|
||||
{bilde.overskrift}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Baksiden av polaroid-kortet */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[#fce7f3] p-[15px] flex flex-col items-center justify-center text-center rounded-sm shadow-xl"
|
||||
style={{ backfaceVisibility: "hidden", transform: "rotateY(180deg)" }}
|
||||
>
|
||||
<div className="text-pink-600 mb-3 drop-shadow-sm">
|
||||
<Icon size={28} strokeWidth={2} />
|
||||
</div>
|
||||
<h2 className="font-serif font-bold text-lg text-gray-900 mb-2">
|
||||
{bilde.overskrift}
|
||||
</h2>
|
||||
<p className="font-sans text-[11px] font-medium text-gray-800 leading-relaxed">
|
||||
{bilde.tekst}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [customImages, setCustomImages] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Last inn lagrede bilder fra IndexedDB (nettleserens minne) slik at brukeren ikke mister dem
|
||||
localforage.getItem<string[]>("savedImages").then((images) => {
|
||||
if (images) {
|
||||
setCustomImages(images);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
|
||||
const promises = files.map((file) => {
|
||||
return new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(promises).then((newImages) => {
|
||||
setCustomImages((prev) => {
|
||||
const updatedImages = [...prev, ...newImages];
|
||||
// Lagre permanent i IndexedDB
|
||||
localforage.setItem("savedImages", updatedImages);
|
||||
return updatedImages;
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearImages = () => {
|
||||
localforage.removeItem("savedImages");
|
||||
setCustomImages([]);
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const combinedImages = [...customImages, ...opplastedeBilderUrl];
|
||||
const activeImages = combinedImages.length > 0 ? combinedImages : standardBilder;
|
||||
|
||||
const currentMineBilder = activeImages.map((url, index) => {
|
||||
const tekstValg = kjærlighetsTekster[index % kjærlighetsTekster.length];
|
||||
return {
|
||||
id: index + 1,
|
||||
url: url,
|
||||
overskrift: tekstValg.overskrift,
|
||||
tekst: tekstValg.tekst,
|
||||
ikon: tekstValg.ikon,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative w-screen h-screen overflow-hidden bg-black font-sans text-white">
|
||||
{/* Animert og pulserende bakgrunn */}
|
||||
<motion.div
|
||||
className="absolute inset-0 z-0 bg-[#1a0b1e] bg-[radial-gradient(circle_at_center,#3d0a21_0%,#1a0b1e_100%)]"
|
||||
animate={{
|
||||
backgroundPosition: ["0% 0%", "100% 100%", "0% 0%"],
|
||||
}}
|
||||
transition={{ duration: 25, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
|
||||
{/* Partikler */}
|
||||
<Particles />
|
||||
|
||||
{/* Romantisk overskrift i toppen */}
|
||||
<div className="absolute top-10 w-full text-center z-10 pointer-events-none font-serif text-[#fce7f3]">
|
||||
<span className="text-4xl md:text-5xl italic block drop-shadow-lg">
|
||||
Til Mika,
|
||||
</span>
|
||||
<span className="text-sm md:text-base uppercase tracking-widest opacity-80 mt-2 block">
|
||||
fra Eivind ❤️
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Container for polaroid-kortene */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Mørkt overlay som dukker opp når et kort er valgt */}
|
||||
<AnimatePresence>
|
||||
{selectedId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
// Pass på at z-index er mindre enn kortet (som nå er 50)
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm z-30 cursor-pointer pointer-events-auto"
|
||||
onClick={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{currentMineBilder.map((bilde, index) => (
|
||||
<div key={bilde.id} className="pointer-events-auto">
|
||||
<FloatingCard
|
||||
bilde={bilde}
|
||||
isSelected={selectedId === bilde.id}
|
||||
onSelect={() => setSelectedId(bilde.id)}
|
||||
index={index}
|
||||
total={currentMineBilder.length}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
src/bilder/1.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/bilder/10.jpg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
src/bilder/11.jpg
Normal file
|
After Width: | Height: | Size: 412 KiB |
BIN
src/bilder/12.jpg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
src/bilder/13.jpg
Normal file
|
After Width: | Height: | Size: 3.4 MiB |
BIN
src/bilder/14.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
src/bilder/15.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
src/bilder/16.webp
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
src/bilder/17.jpg
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
src/bilder/18.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
src/bilder/2.jpg
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
src/bilder/3.jpg
Normal file
|
After Width: | Height: | Size: 980 KiB |
BIN
src/bilder/4.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
src/bilder/5.jpg
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
src/bilder/6.jpg
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
src/bilder/7.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src/bilder/8.jpg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
src/bilder/9.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
1
src/bilder/les_meg.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Legg bildene dine her! Du trenger ikke å gi dem noen spesielle navn. Bare last dem opp i denne mappen (src/bilder), så dukker de opp automatisk på nettsiden!
|
||||
12
src/index.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;600&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: "Playfair Display", serif;
|
||||
}
|
||||
|
||||
.backface-hidden {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
10
src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
26
tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
22
vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
};
|
||||
});
|
||||