diff --git a/.babelrc b/.babelrc
index 93a29a8..464ac5b 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,7 +1,4 @@
{
"presets": ["next/babel"],
- "plugins": [
- ["@babel/plugin-proposal-decorators", { "legacy": true }],
- ["@babel/plugin-proposal-class-properties", { "loose": true }]
- ]
+ "plugins": ["@babel/plugin-transform-class-properties", "@babel/plugin-proposal-class-properties"]
}
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..464aea2
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,189 @@
+# Environment Variables Template
+# Copy this file to .env.local and fill in your values
+# DO NOT commit .env.local to version control!
+
+# =============================================================================
+# DATABASE
+# =============================================================================
+# PostgreSQL connection string
+# Format: postgresql://username:password@host:port/database?schema=public
+#
+# Local development:
+# DATABASE_URL="postgresql://postgres:password@localhost:5432/salsabeatmachine?schema=public"
+#
+# Production (Supabase example):
+# DATABASE_URL="postgresql://postgres:[YOUR-PASSWORD]@db.xxxxx.supabase.co:5432/postgres"
+#
+# Production (PlanetScale example):
+# DATABASE_URL="mysql://xxxxx:pscale_pw_xxxxx@xxxxx.us-east-3.psdb.cloud/salsabeatmachine?sslaccept=strict"
+
+DATABASE_URL="postgresql://postgres:password@localhost:5432/salsabeatmachine?schema=public"
+
+# =============================================================================
+# AUTHENTICATION
+# =============================================================================
+# JWT Secret Key - used to sign authentication tokens
+# Generate a secure random string: openssl rand -base64 32
+# IMPORTANT: Keep this secret and use a different value in production!
+
+JWT_SECRET="your-super-secret-jwt-key-change-this-in-production-use-openssl-rand-base64-32"
+
+# JWT Token Expiration (in seconds or zeit/ms format)
+# Examples: "30d", "7d", "24h", "3600" (seconds)
+JWT_EXPIRES_IN="30d"
+
+# Password hashing bcrypt rounds (10-12 recommended for production)
+BCRYPT_ROUNDS="10"
+
+# =============================================================================
+# OAUTH PROVIDERS (Optional)
+# =============================================================================
+# Google OAuth
+# Get credentials from: https://console.cloud.google.com/apis/credentials
+GOOGLE_CLIENT_ID=""
+GOOGLE_CLIENT_SECRET=""
+
+# Facebook OAuth
+# Get credentials from: https://developers.facebook.com/apps/
+FACEBOOK_APP_ID=""
+FACEBOOK_APP_SECRET=""
+
+# Apple OAuth
+# Get credentials from: https://developer.apple.com/
+APPLE_CLIENT_ID=""
+APPLE_TEAM_ID=""
+APPLE_KEY_ID=""
+APPLE_PRIVATE_KEY=""
+
+# =============================================================================
+# EMAIL SERVICE (Optional - for verification and password reset)
+# =============================================================================
+# SendGrid
+SENDGRID_API_KEY=""
+SENDGRID_FROM_EMAIL="noreply@salsabeatmachine.org"
+
+# Alternative: AWS SES
+# AWS_SES_ACCESS_KEY=""
+# AWS_SES_SECRET_KEY=""
+# AWS_SES_REGION="us-east-1"
+
+# Alternative: Mailgun
+# MAILGUN_API_KEY=""
+# MAILGUN_DOMAIN=""
+
+# =============================================================================
+# FILE STORAGE
+# =============================================================================
+# AWS S3 (for audio files, avatars, thumbnails)
+AWS_ACCESS_KEY_ID=""
+AWS_SECRET_ACCESS_KEY=""
+AWS_S3_BUCKET="salsabeatmachine-assets"
+AWS_S3_REGION="us-east-1"
+
+# Alternative: Vercel Blob Storage
+# BLOB_READ_WRITE_TOKEN=""
+
+# Alternative: Firebase Storage
+# FIREBASE_STORAGE_BUCKET=""
+
+# =============================================================================
+# REDIS (Optional - for caching and session management)
+# =============================================================================
+# Local Redis
+# REDIS_URL="redis://localhost:6379"
+
+# Upstash Redis (recommended for serverless)
+# Get from: https://console.upstash.com/
+# REDIS_URL="rediss://default:xxxxx@xxxxx.upstash.io:6379"
+
+REDIS_URL=""
+
+# =============================================================================
+# APPLICATION SETTINGS
+# =============================================================================
+# Node environment
+NODE_ENV="development"
+
+# Application URL (used for email links, OAuth callbacks)
+NEXT_PUBLIC_APP_URL="http://localhost:3009"
+
+# API Base URL (for frontend API calls)
+NEXT_PUBLIC_API_URL="http://localhost:3009/api"
+
+# =============================================================================
+# RATE LIMITING
+# =============================================================================
+# Requests per window for anonymous users
+RATE_LIMIT_ANONYMOUS="100"
+
+# Requests per window for authenticated users
+RATE_LIMIT_AUTHENTICATED="1000"
+
+# Rate limit window in seconds
+RATE_LIMIT_WINDOW="900"
+
+# =============================================================================
+# MONITORING & ANALYTICS (Optional)
+# =============================================================================
+# Sentry (error tracking)
+# Get from: https://sentry.io/
+SENTRY_DSN=""
+
+# Google Analytics
+NEXT_PUBLIC_GA_MEASUREMENT_ID=""
+
+# PostHog (product analytics)
+NEXT_PUBLIC_POSTHOG_KEY=""
+NEXT_PUBLIC_POSTHOG_HOST=""
+
+# =============================================================================
+# CDN (Optional)
+# =============================================================================
+# CloudFront Distribution ID (AWS CDN)
+CDN_DISTRIBUTION_ID=""
+
+# CloudFlare CDN
+CLOUDFLARE_ZONE_ID=""
+CLOUDFLARE_API_TOKEN=""
+
+# =============================================================================
+# FEATURE FLAGS (Optional)
+# =============================================================================
+# Enable/disable features
+ENABLE_OAUTH="false"
+ENABLE_EMAIL_VERIFICATION="false"
+ENABLE_SOCIAL_FEATURES="true"
+ENABLE_ANALYTICS="true"
+ENABLE_COMMENTS="true"
+
+# =============================================================================
+# ADMIN SETTINGS
+# =============================================================================
+# Admin email addresses (comma-separated)
+ADMIN_EMAILS="admin@salsabeatmachine.org"
+
+# =============================================================================
+# WEBHOOKS (Optional)
+# =============================================================================
+# Webhook secret for verifying webhook requests
+WEBHOOK_SECRET=""
+
+# =============================================================================
+# PRODUCTION ONLY
+# =============================================================================
+# Database connection pooling (for production)
+# DATABASE_CONNECTION_LIMIT="10"
+
+# Session secret (for production)
+# SESSION_SECRET=""
+
+# =============================================================================
+# NOTES
+# =============================================================================
+# 1. Never commit this file with real values to version control
+# 2. Use different values for development, staging, and production
+# 3. Store production secrets in your hosting platform's environment variables
+# (Vercel, Railway, AWS Secrets Manager, etc.)
+# 4. Rotate secrets regularly, especially after team member departures
+# 5. Use strong, randomly generated values for all secrets
+# 6. Keep a secure backup of production environment variables
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..9282f9f
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,49 @@
+{
+ "parser": "@typescript-eslint/parser",
+ "parserOptions": {
+ "ecmaVersion": 2022,
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "jsx": true
+ }
+ },
+ "plugins": ["react", "jsx-a11y", "import", "@typescript-eslint"],
+ "extends": [
+ "eslint:recommended",
+ "plugin:react/recommended",
+ "plugin:jsx-a11y/recommended",
+ "plugin:import/errors",
+ "plugin:import/warnings",
+ "plugin:import/typescript",
+ "plugin:@typescript-eslint/recommended",
+ "prettier"
+ ],
+ "env": {
+ "browser": true,
+ "es2021": true,
+ "node": true
+ },
+ "rules": {
+ "react/react-in-jsx-scope": "off",
+ "react/prop-types": "off",
+ "import/no-unresolved": "error",
+ "import/named": "error",
+ "import/default": "error",
+ "import/no-named-as-default": "warn",
+ "import/no-named-as-default-member": "warn",
+ "no-unused-vars": "warn",
+ "@typescript-eslint/no-unused-vars": ["warn"],
+ "@typescript-eslint/explicit-function-return-type": "off",
+ "@typescript-eslint/no-explicit-any": "off"
+ },
+ "settings": {
+ "react": {
+ "version": "detect"
+ },
+ "import/resolver": {
+ "node": {
+ "extensions": [".js", ".jsx", ".ts", ".tsx"]
+ }
+ }
+ }
+}
diff --git a/.gitignore b/.gitignore
index c51128a..dae9a36 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,5 @@ yarn-error.log*
# firebase
.firebase
firebase-debug.log
+package.json
+next.config.js
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 498e8f7..4915776 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -6,6 +6,14 @@
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"clinyong.vscode-css-modules"
+ "dbaeumer.vscode-eslint", // ESLint linting
+ "ms-vscode.vscode-typescript-next", // rask TS server
+ "formulahendry.auto-close-tag",
+ "formulahendry.auto-rename-tag",
+ "streetsidesoftware.code-spell-checker",
+ "eamodio.gitlens", // Git superpowers
+ "orta.vscode-jest", // hvis du kjører Jest-tester
+ "msjsdiag.debugger-for-chrome" // Chrome-debug
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..8d0f09b
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,14 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Next.js Debug (Chrome)",
+ "type": "pwa-chrome",
+ "request": "launch",
+ "url": "http://localhost:3009",
+ "webRoot": "${workspaceFolder}",
+ "breakOnLoad": true
+ }
+ ]
+ }
+
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..dd1ee9e
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,20 @@
+{
+ // Bruk Volta-installert Node 14 til scripts i terminalen
+ "terminal.integrated.env.osx": {
+ "PATH": "${env:HOME}/.volta/bin:${env:PATH}"
+ },
+
+ // Prettier som format-motor
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true,
+
+ // ESLint fixer på lagring
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": "explicit"
+ },
+
+ // Snu på disable-varsling for useLayoutEffect (valgfritt)
+ "javascript.inlayHints.parameterNames.enabled": "all",
+ "typescript.tsdk": "node_modules/typescript/lib"
+ }
+
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..2f6ca5c
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,26 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "dev",
+ "type": "npm",
+ "script": "dev",
+ "group": "build",
+ "problemMatcher": []
+ },
+ {
+ "label": "build",
+ "type": "npm",
+ "script": "build",
+ "group": "build",
+ "problemMatcher": []
+ },
+ {
+ "label": "vite-bundle",
+ "type": "npm",
+ "script": "bundle",
+ "detail": "Bygg standalone-widgeten til WordPress"
+ }
+ ]
+ }
+
\ No newline at end of file
diff --git a/README.md b/README.md
index b77c29b..68fad08 100644
--- a/README.md
+++ b/README.md
@@ -24,3 +24,40 @@ npm run dev
```
Then go to http://localhost:3009/ and start hacking!
+
+## Backend Features
+
+This repository includes comprehensive documentation for adding backend functionality to the SalsaNor Beat Machine. The backend would enable user accounts, pattern saving, social features, and analytics.
+
+### 📚 Backend Documentation
+
+- **[Quick Start Guide](./docs/QUICK_START.md)** - ⚡ Get started in 5 minutes
+- **[Backend Summary](./docs/BACKEND_SUMMARY.md)** - High-level overview of proposed backend features
+- **[Backend Architecture](./docs/BACKEND_ARCHITECTURE.md)** - Technical architecture and technology stack
+- **[API Specification](./docs/API_SPECIFICATION.md)** - Complete API endpoint documentation
+- **[Database Schema](./docs/DATABASE_SCHEMA.md)** - Database design with Prisma schema
+- **[Implementation Guide](./docs/IMPLEMENTATION_GUIDE.md)** - Step-by-step implementation instructions
+- **[Environment Variables](.env.example)** - Environment configuration template
+
+### 🎯 Key Backend Features
+
+- **User Authentication** - Register, login, OAuth integration
+- **Pattern Management** - Save, load, and share custom beat patterns
+- **Social Features** - Follow users, like patterns, comment, activity feed
+- **User Profiles** - Customizable profiles with preferences and statistics
+- **Analytics** - Track popular instruments, tempos, and patterns
+- **Dynamic Configurations** - Serve machine configurations via API
+
+### 🚀 Getting Started with Backend
+
+**Quick Setup (5 minutes):** Follow the [Quick Start Guide](./docs/QUICK_START.md) to add authentication and pattern saving.
+
+**Full Implementation:** To add complete backend functionality to this project:
+
+1. Review the [Backend Summary](./docs/BACKEND_SUMMARY.md) for an overview
+2. Follow the [Quick Start Guide](./docs/QUICK_START.md) for initial setup
+3. Use the [Implementation Guide](./docs/IMPLEMENTATION_GUIDE.md) for detailed step-by-step instructions
+4. Refer to the [API Specification](./docs/API_SPECIFICATION.md) as a reference for endpoints
+5. Use the [Database Schema](./docs/DATABASE_SCHEMA.md) for data modeling
+
+The documentation provides everything needed to implement a production-ready backend, from authentication to social features to analytics.
diff --git a/components/beat-indicator.module.scss b/components/beat-indicator.module.scss
new file mode 100644
index 0000000..f95d2ee
--- /dev/null
+++ b/components/beat-indicator.module.scss
@@ -0,0 +1,15 @@
+.beat {
+ width: 12px;
+ height: 12px;
+ border-radius: 9999px; // $radius-full
+ background: rgba(255, 255, 255, 0.2);
+ transition: all 0.15s ease;
+ display: inline-block;
+ margin-right: 0.5rem; // $spacing-2
+}
+
+.active {
+ background: #FFC947;
+ box-shadow: 0 0 15px rgba(0, 245, 255, 0.6);
+ transform: scale(1.3);
+}
diff --git a/components/beat-indicator.tsx b/components/beat-indicator.tsx
index a414d50..d67058c 100644
--- a/components/beat-indicator.tsx
+++ b/components/beat-indicator.tsx
@@ -1,4 +1,4 @@
-import styles from './beat-indicator.module.css';
+import styles from './beat-indicator.module.scss';
import classnames from 'classnames';
export interface IBeatIndicatorProps {
diff --git a/components/beat-machine-ui-glass.module.scss b/components/beat-machine-ui-glass.module.scss
new file mode 100644
index 0000000..9797da4
--- /dev/null
+++ b/components/beat-machine-ui-glass.module.scss
@@ -0,0 +1,87 @@
+@use '../styles/abstracts/mixins-modules' as *;
+
+.container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 1rem; // $spacing-4
+
+ @include respond-to('desktop') {
+ padding: 1.5rem; // $spacing-6
+ }
+}
+
+.controlBar {
+ margin-bottom: 1.5rem; // $spacing-6
+}
+
+.controls {
+ display: flex;
+ gap: 1rem; // $spacing-4
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.bpmControl {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem; // $spacing-3
+ flex: 1;
+ min-width: 280px;
+
+ @include respond-to('mobile') {
+ width: 100%;
+ flex-basis: 100%;
+ }
+}
+
+.bpmLabel {
+ font-size: clamp(0.75rem, 0.5vw + 0.25rem, 0.875rem);
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.bpmValue {
+ font-weight: 600;
+ color: var(--color-golden);
+ min-width: 3ch;
+ text-align: right;
+}
+
+.bpmSlider {
+ flex: 1;
+}
+
+.flavorSelect {
+ display: flex;
+ gap: 0.5rem; // $spacing-2
+
+ @include respond-to('mobile') {
+ width: 100%;
+
+ button {
+ flex: 1;
+ }
+ }
+}
+
+.beatIndicator {
+ display: flex;
+ justify-content: center;
+ padding: 1.25rem; // $spacing-5
+ margin-bottom: 1.5rem; // $spacing-6
+}
+
+.instrumentGrid {
+ display: grid;
+ gap: 1.25rem; // $spacing-5
+
+ @include responsive-grid(5, 3, 2);
+}
+
+.instrumentCard {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 150px;
+ padding: 0.5rem !important; // Minimal padding så tools kan være i hjørnet
+}
diff --git a/components/beat-machine-ui-glass.tsx b/components/beat-machine-ui-glass.tsx
new file mode 100644
index 0000000..73b32fe
--- /dev/null
+++ b/components/beat-machine-ui-glass.tsx
@@ -0,0 +1,142 @@
+import { observer } from 'mobx-react-lite';
+import { useEffect, useState } from 'react';
+import { observable } from 'mobx';
+import PlayArrowIcon from '@mui/icons-material/PlayArrow';
+import PauseIcon from '@mui/icons-material/Pause';
+import StopIcon from '@mui/icons-material/Stop';
+
+import { IMachine } from '../engine/machine-interfaces';
+import { useBeatEngine } from '../hooks/use-beat-engine';
+import { useWindowListener } from '../hooks/use-window-listener';
+import { GlassContainer, GlassButton, GlassSlider } from './ui';
+import { BeatIndicator } from './beat-indicator';
+import { InstrumentTile } from './instrument-tile';
+import styles from './beat-machine-ui-glass.module.scss';
+import { IDefaultMachines } from './beat-machine-ui';
+
+export interface IBeatMachineUIGlassProps {
+ machines: IDefaultMachines;
+}
+
+export const BeatMachineUIGlass = observer(({ machines }: IBeatMachineUIGlassProps) => {
+ const { salsa, merengue } = machines;
+ const engine = useBeatEngine();
+ const [machine, setMachine] = useState(observable(salsa));
+
+ useEffect(() => {
+ if (engine && machine) {
+ engine.machine = machine;
+ }
+ }, [engine, machine]);
+
+ const beatCount = machine.flavor === 'Merengue' ? 4 : 8;
+ const beatDivider = machine.flavor === 'Merengue' ? 2 : 1;
+ const beatIndex = engine?.playing ? Math.round(0.5 + ((engine.beat / beatDivider) % beatCount)) : 0;
+
+ useWindowListener(
+ 'keydown',
+ (event: KeyboardEvent) => {
+ switch (event.key) {
+ case '+':
+ case '=':
+ machine.bpm = Math.min(250, machine.bpm + 5);
+ break;
+ case '-':
+ machine.bpm = Math.max(80, machine.bpm - 5);
+ break;
+ case 'k':
+ machine.keyNote = (machine.keyNote + 7) % 12;
+ break;
+ case 'K':
+ machine.keyNote = (machine.keyNote + 5) % 12;
+ break;
+ }
+ if (event.key >= '0' && event.key <= '9') {
+ const index = (parseInt(event.key, 10) + 10 - 1) % 10;
+ const instrument = machine.instruments[index];
+ if (instrument) {
+ if (event.altKey) {
+ instrument.activeProgram = (instrument.activeProgram + 1) % instrument.programs.length;
+ } else {
+ instrument.enabled = !instrument.enabled;
+ }
+ }
+ }
+ },
+ [machine],
+ );
+
+ const handlePlayPause = () => {
+ if (engine?.playing) {
+ engine?.stop();
+ } else {
+ engine?.play();
+ }
+ };
+
+ const handleStop = () => {
+ engine?.stop();
+ };
+
+ return (
+
+ {/* Control Bar */}
+
+
+
:
}
+ onClick={handlePlayPause}
+ >
+ {engine?.playing ? 'Pause' : 'Play'}
+
+
} onClick={handleStop}>
+ Stop
+
+
+
+ BPM:
+ {machine.bpm}
+ (machine.bpm = value)}
+ className={styles.bpmSlider}
+ />
+
+
+
+ setMachine(observable(salsa))}
+ >
+ Salsa
+
+ setMachine(observable(merengue))}
+ >
+ Merengue
+
+
+
+
+
+ {/* Beat Indicator */}
+
+
+
+
+ {/* Instrument Grid */}
+
+ {machine.instruments.map((instrument) => (
+
+
+
+ ))}
+
+
+ );
+});
diff --git a/components/beat-machine-ui.module.css b/components/beat-machine-ui.module.css
index 409e674..a63694e 100644
--- a/components/beat-machine-ui.module.css
+++ b/components/beat-machine-ui.module.css
@@ -5,6 +5,11 @@
padding: 16px;
border-radius: 4px;
}
+.card {
+ display: grid;
+ grid-template-columns: 1fr 3fr 1fr;
+ gap: 16px;
+}
.instrumentList {
display: flex;
@@ -20,3 +25,18 @@
.instrumentTile {
margin-bottom: 12px;
}
+.MuiSlider-root {
+ width: 100% !important;
+ height: 8px !important;
+ background-color: #ccc !important;
+ margin-top: 10px;
+}
+
+.MuiSlider-track {
+ background-color: #3f51b5 !important;
+}
+
+.MuiSlider-thumb {
+ background-color: #3f51b5 !important;
+ border: 2px solid white !important;
+}
diff --git a/components/beat-machine-ui.tsx b/components/beat-machine-ui.tsx
index 66d9dba..8bd52b1 100644
--- a/components/beat-machine-ui.tsx
+++ b/components/beat-machine-ui.tsx
@@ -1,16 +1,21 @@
import {
Button,
ButtonGroup,
+ Container,
FormControl,
Grid,
IconButton,
InputLabel,
Select,
Slider,
+ Stack,
Typography,
-} from '@material-ui/core';
-import PauseIcon from '@material-ui/icons/Pause';
-import PlayIcon from '@material-ui/icons/PlayArrow';
+} from '@mui/material';
+
+import PauseIcon from '@mui/icons-material/Pause';
+import PlayIcon from '@mui/icons-material/PlayArrow';
+import PauseCircleOutlineIcon from '@mui/icons-material/PauseCircleOutline';
+import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
import classnames from 'classnames';
import { observable } from 'mobx';
import { observer } from 'mobx-react-lite';
@@ -19,7 +24,7 @@ import { IMachine } from '../engine/machine-interfaces';
import { useBeatEngine } from '../hooks/use-beat-engine';
import { useWindowListener } from '../hooks/use-window-listener';
import { BeatIndicator } from './beat-indicator';
-import styles from './beat-machine-ui.module.css';
+import styles from './css/beat-machine-ui.module.css';
import { InstrumentTile } from './instrument-tile';
export interface IDefaultMachines {
@@ -90,80 +95,105 @@ export const BeatMachineUI = observer(({ machines }: IBeatMachineUIProps) => {
};
return (
-
+
-
-
+
+
- {engine?.playing ? : }
+ {engine?.playing ? (
+
+ ) : (
+
+ )}
-
- (machine.bpm = newValue as number)}
- />
-
-
-
- {machine.bpm} BPM
-
-
-
-
- {engine && salsa && merengue && (
-
-
-
-
- )}
-
-
-
-
- Key
-
-
+
+
+
+
+
-
-
-
-
@@ -173,6 +203,6 @@ export const BeatMachineUI = observer(({ machines }: IBeatMachineUIProps) => {
))}
-
+
);
});
diff --git a/components/css/beat-indicator.module copy.css b/components/css/beat-indicator.module copy.css
new file mode 100644
index 0000000..fc56bf3
--- /dev/null
+++ b/components/css/beat-indicator.module copy.css
@@ -0,0 +1,14 @@
+.beat {
+ box-sizing: content-box;
+ display: inline-block;
+ height: 18px;
+ width: 18px;
+ border-radius: 11px;
+ border: #333 groove 2px;
+ background-color: #400;
+ margin-right: 4px;
+}
+
+.active {
+ background-color: red;
+}
diff --git a/components/css/beat-indicator.module.css b/components/css/beat-indicator.module.css
new file mode 100644
index 0000000..19aab7c
--- /dev/null
+++ b/components/css/beat-indicator.module.css
@@ -0,0 +1,14 @@
+.beat {
+ box-sizing: content-box;
+ display: inline-block;
+ height: 18px;
+ width: 18px;
+ border-radius: 11px;
+ border: #333 groove 2px;
+ background-color: #400;
+ margin-right: 4px;
+}
+
+.active {
+ background-color: yellow;
+}
diff --git a/components/css/beat-machine-ui.module copy 2.css b/components/css/beat-machine-ui.module copy 2.css
new file mode 100644
index 0000000..44a5c0c
--- /dev/null
+++ b/components/css/beat-machine-ui.module copy 2.css
@@ -0,0 +1,99 @@
+/* ───────── GENERELT ───────── */
+.wrapper {
+ font-family: system-ui, sans-serif;
+}
+
+/* Kort/stil */
+.card {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.8rem 1.1rem;
+ background: #ffffff;
+ border-radius: 12px;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
+ max-width: 480px;
+ margin-bottom: 1rem;
+}
+
+/* Ikonkolonne */
+.icon {
+ flex: 0 0 56px;
+ height: 56px;
+ border-radius: 12px;
+ background: #f3f3f3;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 2rem;
+ transition: background 0.1s ease;
+}
+.icon.active {
+ background: var(--accent);
+ color: #fff;
+}
+
+/* Høyre panel */
+.panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0; /* nødv. for overflow riktig */
+}
+.row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+/* Instrumentnavn */
+.label {
+ font-weight: 600;
+ margin-right: auto; /* skyv resten mot høyre */
+}
+
+/* Play-knapp */
+.play {
+ width: 46px !important;
+ height: 46px !important;
+ border-radius: 50% !important;
+ background: #111 !important;
+ color: #fff !important;
+}
+.play:hover {
+ background: #222 !important;
+}
+
+/* Tempo */
+.tempo {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 180px;
+}
+.slider {
+ flex: 1;
+}
+.bpm {
+ width: 70px;
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+/* Beat-indikator */
+.indicator {
+ margin-left: 0.5rem;
+}
+
+/* ───────── INSTRUMENTLISTE (som før, kun paddings) ───────── */
+.instrumentList {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 0.75rem;
+ padding: 0.5rem;
+}
+.instrumentTile {
+ min-width: 0;
+}
diff --git a/components/css/beat-machine-ui.module copy.css b/components/css/beat-machine-ui.module copy.css
new file mode 100644
index 0000000..409e674
--- /dev/null
+++ b/components/css/beat-machine-ui.module copy.css
@@ -0,0 +1,22 @@
+.card {
+ background: white;
+ margin-bottom: 10px;
+ box-shadow: 0 2px 1px -1px rgba(0, 0, 0, 0.2), 0 1px 1px 0 rgba(0, 0, 0, 0.14), 0 1px 3px 0 rgba(0, 0, 0, 0.12);
+ padding: 16px;
+ border-radius: 4px;
+}
+
+.instrumentList {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+}
+
+.controlsIndicator {
+ margin-top: 16px;
+ text-align: center;
+}
+
+.instrumentTile {
+ margin-bottom: 12px;
+}
diff --git a/components/css/beat-machine-ui.module.css b/components/css/beat-machine-ui.module.css
new file mode 100644
index 0000000..9f22fb6
--- /dev/null
+++ b/components/css/beat-machine-ui.module.css
@@ -0,0 +1,70 @@
+/* ==============================
+ Kort Styling
+============================== */
+.card {
+ background-color: #ffffff;
+ margin-bottom: 16px;
+ box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
+ padding: 20px;
+ border-radius: 8px;
+ transition: all 0.3s ease;
+}
+
+.card:hover {
+ box-shadow: 0px 6px 12px rgba(0, 0, 0, 0.15);
+}
+
+/* ==============================
+ Instrument Liste
+============================== */
+.instrumentList {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 12px;
+ margin-top: 12px;
+}
+
+/* ==============================
+ Instrument Tile
+============================== */
+.instrumentTile {
+ background-color: #f5f5f5;
+ border-radius: 8px;
+ padding: 12px;
+ box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
+ transition: all 0.3s ease;
+}
+
+.instrumentTile:hover {
+ box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.15);
+}
+
+/* ==============================
+ Kontroll Indikator
+============================== */
+.controlsIndicator {
+ margin-top: 24px;
+ text-align: center;
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+}
+
+/* ==============================
+ Responsiv justering
+============================== */
+@media (max-width: 768px) {
+ .card {
+ padding: 16px;
+ }
+
+ .instrumentList {
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ }
+}
+
+@media (max-width: 480px) {
+ .instrumentList {
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ }
+}
diff --git a/components/css/instrument-tile.module.css b/components/css/instrument-tile.module.css
new file mode 100644
index 0000000..81f7703
--- /dev/null
+++ b/components/css/instrument-tile.module.css
@@ -0,0 +1,132 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ font-size: 12px;
+ width: 100%;
+ height: 100%;
+ position: relative;
+ align-items: center;
+ justify-content: center;
+}
+
+.thumbnail {
+ cursor: pointer;
+ background: no-repeat 50% 50%;
+ background-size: contain;
+ width: 96px;
+ height: 88px;
+}
+
+.thumbnail.disabled {
+ filter: url(#gray-overlay);
+}
+
+.main {
+ position: relative;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 88px;
+}
+
+.bottom {
+ font-size: 18px;
+ height: 40px;
+ width: 100%;
+}
+
+.tools {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ display: flex;
+ gap: 4px;
+ opacity: 0;
+ transition: opacity 0.3s ease, transform 0.3s ease;
+ transform: translateY(8px);
+ z-index: 10;
+}
+
+.tools .iconButton {
+ color: rgba(255, 255, 255, 0.95);
+ padding: 6px;
+ background: rgba(0, 0, 0, 0.4);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ border-radius: 6px;
+ transition: all 0.2s ease;
+ min-width: auto;
+ width: 32px;
+ height: 32px;
+}
+
+.tools .iconButton:hover {
+ background: rgba(0, 0, 0, 0.65);
+ transform: scale(1.1);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+
+.tools .iconButton svg {
+ font-size: 18px;
+}
+
+.tools .active {
+ color: #FFC947;
+ background: rgba(255, 201, 71, 0.2);
+ box-shadow: 0 0 12px rgba(255, 201, 71, 0.4);
+}
+
+.instrumentLabel {
+ opacity: 0;
+ transition: opacity ease 0.3s;
+ text-align: center;
+}
+
+.container:hover .instrumentLabel,
+.container:hover .tools {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.settingsPanel {
+ position: absolute;
+ bottom: 45px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 12px;
+ background: rgba(0, 0, 0, 0.85);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+ animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
+ width: 90%;
+ max-width: 200px;
+ z-index: 100;
+}
+
+.settingsPanel :global(.MuiSlider-root) {
+ color: #FFC947;
+}
+
+.settingsPanel :global(.MuiSelect-root) {
+ color: rgba(255, 255, 255, 0.95);
+}
+
+.settingsPanel :global(.MuiOutlinedInput-notchedOutline) {
+ border-color: rgba(255, 255, 255, 0.2);
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(12px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+}
+
diff --git a/components/css/mobile-app-links.module.css b/components/css/mobile-app-links.module.css
new file mode 100644
index 0000000..ead400f
--- /dev/null
+++ b/components/css/mobile-app-links.module.css
@@ -0,0 +1,8 @@
+.mobileLinks {
+ text-align: center;
+ margin: 16px 0 24px;
+}
+
+.mobileLinks a:not(:first-child) {
+ margin-left: 16px;
+}
diff --git a/components/instrument-tile.module.css b/components/instrument-tile.module.css
index 2afc150..b93c80f 100644
--- a/components/instrument-tile.module.css
+++ b/components/instrument-tile.module.css
@@ -21,6 +21,7 @@
.main {
display: flex;
position: relative;
+ flex-direction: column;
}
.bottom {
@@ -30,21 +31,40 @@
}
.tools {
- background: rgba(255, 255, 255, 0.5);
- bottom: 0;
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ display: flex;
+ gap: 4px;
+ opacity: 0;
+ transition: opacity 0.3s ease, transform 0.3s ease;
+ transform: translateY(4px);
}
.tools .iconButton {
- color: black;
- padding: 8px;
+ color: rgba(255, 255, 255, 0.9);
+ padding: 6px;
+ background: rgba(0, 0, 0, 0.4);
+ backdrop-filter: blur(8px);
+ border-radius: 6px;
+ transition: all 0.2s ease;
+}
+
+.tools .iconButton:hover {
+ background: rgba(0, 0, 0, 0.6);
+ transform: scale(1.1);
+}
+
+.tools .iconButton svg {
+ font-size: 18px;
}
.tools .active {
- color: blue;
+ color: #FFC947;
+ background: rgba(255, 201, 71, 0.2);
}
-.instrumentLabel,
-.tools {
+.instrumentLabel {
opacity: 0;
transition: opacity ease 0.3s;
text-align: center;
@@ -53,5 +73,26 @@
.container:hover .instrumentLabel,
.container:hover .tools {
opacity: 1;
+ transform: translateY(0);
+}
+
+.settingsPanel {
+ margin-top: 8px;
+ padding: 12px;
+ background: rgba(0, 0, 0, 0.3);
+ backdrop-filter: blur(10px);
+ border-radius: 8px;
+ animation: slideUp 0.2s ease;
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
diff --git a/components/instrument-tile.tsx b/components/instrument-tile.tsx
index 6f7276e..947d519 100644
--- a/components/instrument-tile.tsx
+++ b/components/instrument-tile.tsx
@@ -1,11 +1,11 @@
-import { FormControl, IconButton, Select, Slider } from '@material-ui/core';
+import { FormControl, IconButton, Select, Slider } from '@mui/material';
import classnames from 'classnames';
import { observer } from 'mobx-react-lite';
-import { useState } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { IInstrument } from '../engine/machine-interfaces';
-import styles from './instrument-tile.module.css';
-import SettingsIcon from '@material-ui/icons/Settings';
-import VolumeIcon from '@material-ui/icons/VolumeUp';
+import styles from './css/instrument-tile.module.css';
+import SettingsIcon from '@mui/icons-material/Settings';
+import VolumeIcon from '@mui/icons-material/VolumeUp';
interface IInstrumentTileProps {
instrument: IInstrument;
@@ -15,7 +15,24 @@ export const InstrumentTile = observer(({ instrument }: IInstrumentTileProps) =>
const [showSettings, setShowSettings] = useState(false);
const [showVolume, setShowVolume] = useState(false);
const [previousVolume, setPreviousVolume] = useState(instrument.volume);
- const showTitle = !showSettings && !showVolume;
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setShowSettings(false);
+ setShowVolume(false);
+ }
+ };
+
+ if (showSettings || showVolume) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [showSettings, showVolume]);
const toggle = () => {
if (instrument.enabled) {
@@ -28,7 +45,7 @@ export const InstrumentTile = observer(({ instrument }: IInstrumentTileProps) =>
};
return (
-
+
{
setShowSettings(!showSettings);
setShowVolume(false);
@@ -47,6 +65,7 @@ export const InstrumentTile = observer(({ instrument }: IInstrumentTileProps) =>
{
setShowVolume(!showVolume);
setShowSettings(false);
@@ -56,33 +75,38 @@ export const InstrumentTile = observer(({ instrument }: IInstrumentTileProps) =>
- {showTitle &&
{instrument.title}
}
+
{instrument.title}
{showVolume && (
-
{
- instrument.volume = newValue as number;
- }}
- />
+
+ {
+ instrument.volume = newValue as number;
+ }}
+ />
+
)}
{showSettings && (
-
- (instrument.activeProgram = parseInt(e.target.value as string, 10) - 1)}
- >
- {instrument.programs.map((program, index) => (
-
- ))}
-
-
+
+
+ (instrument.activeProgram = parseInt(String(e.target.value), 10) - 1)}
+ >
+ {instrument.programs.map((program, index) => (
+
+ ))}
+
+
+
)}
{/* filter is used by CSS to draw disabled instruments */}