feat: initial commit met Dockerfile voor Coolify deployment
- React/Express slotmachine marketing app - Multi-stage Dockerfile (node:22-alpine) - .dockerignore toegevoegd - package-lock.json gegenereerd Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
ddfb32444a
17 changed files with 5946 additions and 0 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
9
.env.example
Normal file
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"
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Stage 1: build
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: runtime
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "dist/server.cjs"]
|
||||||
20
README.md
Normal file
20
README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/68cdb4a0-8a1b-42dc-9e70-57384f437da0
|
||||||
|
|
||||||
|
## 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
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>
|
||||||
|
|
||||||
6
metadata.json
Normal file
6
metadata.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "AI Marketing App Slotmachine",
|
||||||
|
"description": "Een cartooneske slotmachine die AI app-ideeën genereert voor Nederlandse marketeers, compleet met kant-en-klare, kopieerbare prompts.",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||||
|
}
|
||||||
4344
package-lock.json
generated
Normal file
4344
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
package.json
Normal file
35
package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "react-example",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx server.ts",
|
||||||
|
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
|
||||||
|
"start": "node dist/server.cjs",
|
||||||
|
"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",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"react": "^19.0.1",
|
||||||
|
"react-dom": "^19.0.1",
|
||||||
|
"vite": "^6.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"motion": "^12.23.24"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@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",
|
||||||
|
"@types/express": "^4.17.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
117
server.ts
Normal file
117
server.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import express from "express";
|
||||||
|
import path from "path";
|
||||||
|
import { createServer as createViteServer } from "vite";
|
||||||
|
import { GoogleGenAI } from "@google/genai";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
async function startServer() {
|
||||||
|
const app = express();
|
||||||
|
const PORT = 3000;
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Initialize Gemini client safely
|
||||||
|
let ai: GoogleGenAI | null = null;
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY;
|
||||||
|
if (apiKey) {
|
||||||
|
ai = new GoogleGenAI({
|
||||||
|
apiKey: apiKey,
|
||||||
|
httpOptions: {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'aistudio-build',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Route to enhance the prompt using Gemini
|
||||||
|
app.post("/api/enhance-prompt", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { slot1, slot2, slot3, appName } = req.body;
|
||||||
|
if (!slot1 || !slot2 || !slot3) {
|
||||||
|
return res.status(400).json({ error: "Onvoldoende gegevens om prompt te genereren." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanAppName = appName || `Marketeer AI Tool - ${slot1}`;
|
||||||
|
|
||||||
|
if (!ai) {
|
||||||
|
// Fallback if no API key is configured
|
||||||
|
return res.json({
|
||||||
|
enhancedPrompt: `# SYSTEEMPROMPT: ${cleanAppName}\n\nAls de GEMINI_API_KEY niet is geconfigureerd in AI Studio Secrets, kunt u dit basis-concept gebruiken voor uw marketing AI app:\n\n## Concept Fiche\n- **Doelgroep/Kanaal**: ${slot1}\n- **AI Functie**: ${slot2}\n- **USP / Waarde**: ${slot3}\n\n## Instructie\nOntwerp een overzichtelijke web-app voor Nederlandse marketeers die ${slot1} helpt door middel van ${slot2}, met als direct resultaat dat het ${slot3}!`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptInput = `
|
||||||
|
Je bent een expert in AI-marketing en Prompt Engineering. Schrijf een extreem krachtige, professionele, en gedetailleerde "Systeem Prompt" (in het Nederlands) die een marketeer direct kan kopiëren en in een AI-model (zoals Claude, ChatGPT, Gemini, of Cursor) kan stoppen om een werkende applicatie te bouwen.
|
||||||
|
|
||||||
|
De slotmachine heeft de volgende combinatie gekozen:
|
||||||
|
1. Doelgroep / Kanaal: ${slot1}
|
||||||
|
2. Kern AI-technologie of actie: ${slot2}
|
||||||
|
3. Marketing-waarde of USP: ${slot3}
|
||||||
|
Voorlopige naam van de app: ${cleanAppName}
|
||||||
|
|
||||||
|
De prompt moet een vaste, gestructureerde en hoogwaardige opbouw hebben.
|
||||||
|
Schrijf de prompt in het Nederlands. Gebruik de jij-vorm.
|
||||||
|
Geef een instructie die het AI-model dwingt om een complete, werkende full-stack web-app te schrijven.
|
||||||
|
|
||||||
|
De structuur MOET zijn:
|
||||||
|
# SYSTEEMPROMPT: [Voeg hier een creatieve, professionele, bruikbare AI-appnaam toe gerelateerd aan de invoer]
|
||||||
|
|
||||||
|
## Rol & Identiteit
|
||||||
|
Uitleg over welke specifieke marketing-expert of product development rol het AI-model moet aannemen (bijv. "Je bent een topklasse marketingtech-ontwikkelaar met expertise in ${slot1}...").
|
||||||
|
|
||||||
|
## Concept & Toegevoegde Waarde
|
||||||
|
Een toelichting waarom deze app geniaal is voor ${slot1}, hoe ${slot2} technisch optimaal wordt ingezet, en hoe het de unieke waarde "${slot3}" waarmaakt voor de marketeer.
|
||||||
|
|
||||||
|
## Cruciale Functionele Specificaties
|
||||||
|
Geef een overzicht van minimaal 3 specifieke interactieve functies die deze app MOET bevatten (bijv. een invoerformulier waar de gebruiker input geeft, een real-time AI-generatie preview, een handige "Kopieer naar klembord" of "Exporteren als CSV/PDF" knop, of visualisatie-dashboards).
|
||||||
|
|
||||||
|
## Technische Architectuur (Gebruiksvriendelijk)
|
||||||
|
- Frontend: HTML5, Tailwind CSS (voor een prachtig, modern, minimalistisch en responsief ontwerp in lichte en donkere modus), React of vanilla JS.
|
||||||
|
- Backend: Eenvoudige Node.js of standalone client met een schone mock of proxy voor API-keys zodat de marketeer de code direct kan draaien.
|
||||||
|
|
||||||
|
## Stapsgewijs Ontwikkel- en Promptingplan
|
||||||
|
Instructies aan de AI-assistent over hoe het de app in logische, hapklare stappen moet leveren aan de marketeer (eerst de layout-codering, dan de logica-integratie, dan de deployment-tips op Netlify of Vercel).
|
||||||
|
|
||||||
|
## Testscenario's & Marketing Invoer-voorbeelden
|
||||||
|
Geef ten minste 2 concrete marketing-voorbeelden (gerelateerd aan ${slot1}) die de marketeer direct in de app als test-invoer kan gebruiken om te zien of deze app perfect presteert.
|
||||||
|
|
||||||
|
Zorg dat de uiteindelijke output in prachtig opgemaakte Markdown is, met heldere scheidingslijnen (---), vetgedrukte koppen en bulletpoints. Begin direct met de Markdown van de prompt zelf, zonder inleidende of afsluitende zinnen van jou (geen "Natuurlijk! Hier is je prompt:").
|
||||||
|
`;
|
||||||
|
|
||||||
|
const response = await ai.models.generateContent({
|
||||||
|
model: "gemini-3.5-flash",
|
||||||
|
contents: promptInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enhancedPrompt = response.text || "";
|
||||||
|
res.json({ enhancedPrompt });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Fout bij aanroepen Gemini API: ", error);
|
||||||
|
res.status(500).json({ error: "Er is een fout opgetreden bij het verrijken van de prompt.", details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vite middleware for development
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
const vite = await createViteServer({
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
appType: "spa",
|
||||||
|
});
|
||||||
|
app.use(vite.middlewares);
|
||||||
|
} else {
|
||||||
|
const distPath = path.join(process.cwd(), 'dist');
|
||||||
|
app.use(express.static(distPath));
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(distPath, 'index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, "0.0.0.0", () => {
|
||||||
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer();
|
||||||
1009
src/App.tsx
Normal file
1009
src/App.tsx
Normal file
File diff suppressed because it is too large
Load diff
97
src/data.ts
Normal file
97
src/data.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
export interface SlotItem {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const slot1Dataset: SlotItem[] = [
|
||||||
|
{ id: 1, text: "B2B websitebezoekers op LinkedIn", emoji: "💼" },
|
||||||
|
{ id: 2, text: "Gen-Z op TikTok en Instagram Reels", emoji: "🎬" },
|
||||||
|
{ id: 3, text: "Slapende e-commerce klanten via e-mail", emoji: "✉️" },
|
||||||
|
{ id: 4, text: "Lokale consumenten in Google Maps", emoji: "📍" },
|
||||||
|
{ id: 5, text: "Podcast-luisteraars tijdens de ochtendspits", emoji: "🎧" },
|
||||||
|
{ id: 6, text: "Bezoekers met een verlaten winkelmandje", emoji: "🛒" },
|
||||||
|
{ id: 7, text: "Trouwe fans in een loyaliteitsprogramma", emoji: "💎" },
|
||||||
|
{ id: 8, text: "Nieuwe abonnees op de nieuwsbrief", emoji: "📰" },
|
||||||
|
{ id: 9, text: "Eventbezoekers via gepersonaliseerde WhatsApps", emoji: "📱" },
|
||||||
|
{ id: 10, text: "Websitebezoekers met hoge aankoopintentie", emoji: "🔥" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const slot2Dataset: SlotItem[] = [
|
||||||
|
{ id: 1, text: "Toonaanpassing & Sentimentanalyse", emoji: "🎭" },
|
||||||
|
{ id: 2, text: "Geautomatiseerde Video Shorts Generator", emoji: "⭐" },
|
||||||
|
{ id: 3, text: "Hyper-persoonlijke AI Predictive Copywriting", emoji: "✍️" },
|
||||||
|
{ id: 4, text: "Interactieve AI product-aanbeveler & quiz", emoji: "🧩" },
|
||||||
|
{ id: 5, text: "Geautomatiseerde SEO-landingspagina schrijver", emoji: "📝" },
|
||||||
|
{ id: 6, text: "Slimme LinkedIn carrousel & infographic generator", emoji: "📊" },
|
||||||
|
{ id: 7, text: "AI-gestuurde feedback & review samenvatter", emoji: "💬" },
|
||||||
|
{ id: 8, text: "Dynamische prijsstelling & voordeel calculator", emoji: "⚖️" },
|
||||||
|
{ id: 9, text: "Klantenservice chatbot met humor-instelling", emoji: "🤖" },
|
||||||
|
{ id: 10, text: "Micro-influencer matchmaker & campagneplanner", emoji: "🤝" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const slot3Dataset: SlotItem[] = [
|
||||||
|
{ id: 1, text: "die de conversie direct met 20% verhoogt", emoji: "📈" },
|
||||||
|
{ id: 2, text: "om gegarandeerd viraal te gaan op socials", emoji: "🚀" },
|
||||||
|
{ id: 3, text: "die het bouncepercentage drastisch verlaagt", emoji: "🛡️" },
|
||||||
|
{ id: 4, text: "om koude leads in 3 stappen op te warmen", emoji: "☕" },
|
||||||
|
{ id: 5, text: "die de wekelijkse schrijftijd met 10 uur verkort", emoji: "⏱️" },
|
||||||
|
{ id: 6, text: "voor 100% consistente merk-branding", emoji: "🎨" },
|
||||||
|
{ id: 7, text: "die de advertentiekosten met de helft vermindert", emoji: "💸" },
|
||||||
|
{ id: 8, text: "om leads binnen 5 minuten op te volgen", emoji: "⚡" },
|
||||||
|
{ id: 9, text: "voor realtime AI A/B control-testen", emoji: "🧪" },
|
||||||
|
{ id: 10, text: "om de lokale organische vindbaarheid te skyrocketten", emoji: "🎯" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const wackySlot1: SlotItem[] = [
|
||||||
|
{ id: 101, text: "Koffieverslaafde kantoorduiven", emoji: "🐦" },
|
||||||
|
{ id: 102, text: "De geit van de buurman met LinkedIn Premium", emoji: "🐐" },
|
||||||
|
{ id: 103, text: "Holografische dinosaurussen in een tikkie-groep", emoji: "🦖" },
|
||||||
|
{ id: 104, text: "Boze kabouters in de serverruimte", emoji: "🍄" },
|
||||||
|
{ id: 105, text: "Spitvlammende alpacas op een agile standup", emoji: "🦙" },
|
||||||
|
{ id: 106, text: "Een pratende kaassoufflé genaamd Evert", emoji: "🧀" },
|
||||||
|
{ id: 107, text: "De linkerschoen van de Senior Growth Hacker", emoji: "👟" },
|
||||||
|
{ id: 108, text: "Existentiële stofmijten in de vergaderzaal", emoji: "🌌" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const wackySlot2: SlotItem[] = [
|
||||||
|
{ id: 101, text: "Vertaalt Excel-sheets naar Gregoriaans Gezang", emoji: "🎵" },
|
||||||
|
{ id: 102, text: "Teleporteert koude bitterballen rechtstreeks naar Slack", emoji: "🛸" },
|
||||||
|
{ id: 103, text: "Laat de muiscursor de Sirtaki dansen", emoji: "💃" },
|
||||||
|
{ id: 104, text: "Verandert PDF-facturen in Origami-kraanvogels", emoji: "📄" },
|
||||||
|
{ id: 105, text: "Sproeit digitale confetti bij elke onnodige meeting", emoji: "🎉" },
|
||||||
|
{ id: 106, text: "Laat de koffiemachine stiekem passief-agressief Frans praten", emoji: "☕" },
|
||||||
|
{ id: 107, text: "Voorspelt of de printer vandaag zin heeft om te werken", emoji: "🖨️" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const wackySlot3: SlotItem[] = [
|
||||||
|
{ id: 101, text: "zodat je baas spontaan begint te jodelen", emoji: "🏔️" },
|
||||||
|
{ id: 102, text: "om spirituele verlichting van de Belastingdienst te bereiken", emoji: "🧘" },
|
||||||
|
{ id: 103, text: "die de zwaartekracht op de marketingafdeling met 10% verlaagt", emoji: "🪐" },
|
||||||
|
{ id: 104, text: "waardoor alle LinkedIn-berichten in blauwe bessen veranderen", emoji: "🫐" },
|
||||||
|
{ id: 105, text: "wat resulteert in een verplichte polonaise tijdens de wekelijkse demo", emoji: "🥳" },
|
||||||
|
{ id: 106, text: "waardoor de kantoorkat de nieuwe CMO wordt", emoji: "🐱" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const wackyLoadingPhrases = [
|
||||||
|
"Quantum-bitterballen frituren...",
|
||||||
|
"Existentiële crises verwerken in Excel...",
|
||||||
|
"Sirtaki-algoritmes synchroniseren...",
|
||||||
|
"De koffiemachine overtuigen om Nederlands te praten...",
|
||||||
|
"Polonaise-coördinaten berekenen...",
|
||||||
|
"Origami-vouwen voor de fiscus...",
|
||||||
|
"Kabouters omkopen met koffiepads..."
|
||||||
|
];
|
||||||
|
|
||||||
|
export const loadingPhrases = [
|
||||||
|
"Marketing jargon filteren op synergie...",
|
||||||
|
"Pixel tracking optimaliseren voor conversie...",
|
||||||
|
"Influencers scouten in de Randstad...",
|
||||||
|
"AI-copywriter een sterke bak espresso inschenken...",
|
||||||
|
"LinkedIn algoritme kalibreren op viraliteit...",
|
||||||
|
"CTR en bounce rate balanceren in de cloud...",
|
||||||
|
"Marketeers overtuigen dat koude acquisitie dood is...",
|
||||||
|
"Gen-Z straattaal vertalen naar enterprise-waarde...",
|
||||||
|
"AB-testen voorbereiden met onrealistische KPI's...",
|
||||||
|
"AI-agenten de schuld geven van mislukte marketingcampagnes..."
|
||||||
|
];
|
||||||
68
src/index.css
Normal file
68
src/index.css
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Space+Grotesk:wght@500;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-display: "Space Grotesk", sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom comic/cartoon styling utilities */
|
||||||
|
.cartoon-border {
|
||||||
|
border: 4px solid #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cartoon-shadow {
|
||||||
|
box-shadow: 8px 8px 0px 0px #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cartoon-shadow-sm {
|
||||||
|
box-shadow: 4px 4px 0px 0px #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cartoon-shadow-lg {
|
||||||
|
box-shadow: 12px 12px 0px 0px #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cartoon-shadow-red {
|
||||||
|
box-shadow: 8px 8px 0px 0px #FF6B6B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cartoon-shadow-orange {
|
||||||
|
box-shadow: 8px 8px 0px 0px #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light bulbs blinking animation */
|
||||||
|
@keyframes blink-odd {
|
||||||
|
0%, 100% { opacity: 0.3; background-color: #facc15; }
|
||||||
|
50% { opacity: 1; background-color: #eab308; box-shadow: 0 0 10px #facc15; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink-even {
|
||||||
|
0%, 100% { opacity: 1; background-color: #ef4444; box-shadow: 0 0 10px #ef4444; }
|
||||||
|
50% { opacity: 0.3; background-color: #b91c1c; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulbs-odd {
|
||||||
|
animation: blink-odd 0.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulbs-even {
|
||||||
|
animation: blink-even 0.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth custom scrollbars for prompt markdown */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
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>,
|
||||||
|
);
|
||||||
137
src/utils/audio.ts
Normal file
137
src/utils/audio.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
// Browser Web Audio API synthesizer for retro slot machine sounds
|
||||||
|
|
||||||
|
let audioCtx: AudioContext | null = null;
|
||||||
|
|
||||||
|
function getAudioContext(): AudioContext | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
if (!audioCtx) {
|
||||||
|
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
if (AudioContextClass) {
|
||||||
|
audioCtx = new AudioContextClass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resume if suspended (e.g. by browser autoplay restrictions)
|
||||||
|
if (audioCtx && audioCtx.state === "suspended") {
|
||||||
|
audioCtx.resume();
|
||||||
|
}
|
||||||
|
return audioCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playTick() {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = "sine";
|
||||||
|
osc.frequency.setValueAtTime(800, ctx.currentTime);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(100, ctx.currentTime + 0.05);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playLock() {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const osc2 = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = "triangle";
|
||||||
|
osc.frequency.setValueAtTime(523.25, now); // C5
|
||||||
|
osc.frequency.setValueAtTime(659.25, now + 0.08); // E5
|
||||||
|
|
||||||
|
osc2.type = "sine";
|
||||||
|
osc2.frequency.setValueAtTime(1046.50, now); // C6
|
||||||
|
osc2.frequency.exponentialRampToValueAtTime(1318.51, now + 0.15);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.12, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
osc2.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start();
|
||||||
|
osc2.start();
|
||||||
|
osc.stop(now + 0.2);
|
||||||
|
osc2.stop(now + 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playLeverPull() {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = "sawtooth";
|
||||||
|
osc.frequency.setValueAtTime(150, now);
|
||||||
|
osc.frequency.linearRampToValueAtTime(400, now + 0.2);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.1, now);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start();
|
||||||
|
osc.stop(now + 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playJackpot() {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const notes = [261.63, 329.63, 392.00, 523.25, 659.25, 783.99, 1046.50]; // C Major scale notes
|
||||||
|
|
||||||
|
notes.forEach((freq, idx) => {
|
||||||
|
const noteTime = now + idx * 0.08;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = "square";
|
||||||
|
osc.frequency.setValueAtTime(freq, noteTime);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.06, noteTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, noteTime + 0.25);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start(noteTime);
|
||||||
|
osc.stop(noteTime + 0.25);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playClick() {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = "sine";
|
||||||
|
osc.frequency.setValueAtTime(600, ctx.currentTime);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.05, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + 0.1);
|
||||||
|
}
|
||||||
26
tsconfig.json
Normal file
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
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 : {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue