Initial commit - Til Mika app with polaroid images and scaling fixes

This commit is contained in:
Eivind Røsstad Skeie 2026-06-29 12:15:32 +02:00
commit 0f41af3744
31 changed files with 3496 additions and 0 deletions

9
.env.example Normal file
View 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
View file

@ -0,0 +1,10 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example
.antigravity/
.vscode/

19
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

36
package.json Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
src/bilder/10.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
src/bilder/11.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

BIN
src/bilder/12.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
src/bilder/13.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
src/bilder/14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
src/bilder/15.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
src/bilder/16.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
src/bilder/17.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
src/bilder/18.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
src/bilder/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
src/bilder/3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

BIN
src/bilder/4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
src/bilder/5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

BIN
src/bilder/6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
src/bilder/7.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
src/bilder/8.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
src/bilder/9.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

1
src/bilder/les_meg.txt Normal file
View 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
View 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
View 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
View 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
View 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 : {},
},
};
});