diff --git a/.forgejo/workflows/docker-publish.yml b/.forgejo/workflows/docker-publish.yml new file mode 100644 index 0000000..dce3621 --- /dev/null +++ b/.forgejo/workflows/docker-publish.yml @@ -0,0 +1,28 @@ +name: Build and Publish Docker Image + +on: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: docker + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Codeberg Container Registry + uses: docker/login-action@v3 + with: + registry: codeberg.org + username: ${{ github.repository_owner }} + # Requires a generated Application Token from your Codeberg settings saved as a repository secret + password: ${{ secrets.CODEBERG_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: codeberg.org/${{ github.repository }}:latest \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61da87a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Compile the React application into static files +FROM node:24-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Serve the static files using a lightweight web server +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..52a60e7 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 80; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + # Redirect all unknown paths to index.html to allow React Router to handle the routing + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 51b0dbc..ae63a25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,12 @@ "dependencies": { "@mdi/font": "~7.4.47", "@tailwindcss/vite": "~4.2.2", + "i18next": "~26.0.0", + "i18next-http-backend": "~3.0.2", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-i18next": "~17.0.0", + "react-router-dom": "~7.13.2" }, "devDependencies": { "@vitejs/plugin-react": "^6.0.1", @@ -21,6 +25,15 @@ "vite": "^8.0.1" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -773,6 +786,28 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -863,6 +898,55 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.0.tgz", + "integrity": "sha512-+Dg27j7VH40WRy+Q010hj3jxlxDb3qFkA7EwuK4kx0+glLxEqZsxdB8MqliPBkTrXEuNlEfeosM3lucAHK+0tQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1148,6 +1232,26 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -1162,9 +1266,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -1229,6 +1333,71 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.0.tgz", + "integrity": "sha512-L7aqwOePCExt6nlF7000lN2YKWnR7IpSpQId9sj01798Xn3LAncBdTHKl9lA/nr+YrG78BTqWPJxq9mlrrmH7Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.10.10", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", + "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", + "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.10", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", @@ -1274,6 +1443,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1318,6 +1493,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1356,6 +1537,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", @@ -1432,6 +1622,31 @@ "optional": true } } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } } } } diff --git a/package.json b/package.json index 100608d..ea45ce3 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,12 @@ "dependencies": { "@mdi/font": "~7.4.47", "@tailwindcss/vite": "~4.2.2", + "i18next": "~26.0.0", + "i18next-http-backend": "~3.0.2", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-i18next": "~17.0.0", + "react-router-dom": "~7.13.2" }, "devDependencies": { "@vitejs/plugin-react": "^6.0.1", diff --git a/public/i18n/de.json b/public/i18n/de.json new file mode 100644 index 0000000..6fc0559 --- /dev/null +++ b/public/i18n/de.json @@ -0,0 +1,17 @@ +{ + "IMPRINT": { + "TITLE": "Impressum", + "LEGAL": "Angaben gemäß § 5 DDG", + "CONTACT": "Kontakt" + }, + "LOGIN": { + "TITLE": "Login", + "EMAIL": "Email", + "PASSWORD": "Passwort", + "SUBMIT": "Spiel betreten", + "ERROR_EMAIL": "Gib eine gültige Email-Adresse ein mit '@' und mit '.'.", + "ERROR_PASSWORD": "Das Passwort darf nicht leer sein." + }, + "welcome": "Dein Game-Backlog unter Kontrolle", + "login": "Anmelden" +} \ No newline at end of file diff --git a/public/i18n/en.json b/public/i18n/en.json new file mode 100644 index 0000000..b31a296 --- /dev/null +++ b/public/i18n/en.json @@ -0,0 +1,17 @@ +{ + "IMPRINT": { + "TITLE": "Imprint", + "LEGAL": "Information according to § 5 TMG.", + "CONTACT": "Contact" + }, + "LOGIN": { + "TITLE": "Login", + "EMAIL": "Email", + "PASSWORD": "Password", + "SUBMIT": "Enter the game", + "ERROR_EMAIL": "Enter a valid email address with '@' and with '.'.", + "ERROR_PASSWORD": "The password must have at least one character." + }, + "welcome": "Control your game backlog", + "login": "Login" +} \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/App.jsx b/src/App.jsx index e10478a..eb0ce86 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,67 +1,71 @@ -import React from 'react'; +import {BrowserRouter, Link, Route, Routes} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {useEffect, useState} from 'react'; + +import LandingPage from './pages/LandingPage'; +import LoginPage from './pages/LoginPage'; +import ImprintPage from './pages/ImprintPage'; function App() { - // Array to generate the placeholder game cards - const mockGames = [1, 2, 3]; + const {t, i18n} = useTranslation(); + const [isDarkMode, setIsDarkMode] = useState(true); - return ( -
-
-
- - To Be Played -
-
- - -
-
+ // Sync React state with the HTML class required by Tailwind CSS + useEffect(() => { + if (isDarkMode) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + }, [isDarkMode]); -
-
-

- Dein Game-Backlog unter Kontrolle. -

-

- Willkommen bei deiner neuen Applikation. Hier werden bald deine Spiele aus dem - game-service gelistet. -

-
+ const toggleLanguage = () => { + if (i18n.language === 'de') { + i18n.changeLanguage('en').then(r => console.log("Changed language to 'en'")); + } else { + i18n.changeLanguage('de').then(r => console.log("Changed language to 'de'")); + } + }; -
- {mockGames.map((n) => { - return ( -
-
- {`Beispiel + return ( + +
+
+ + Game Log + + +
+ + + + {t('login')} +
-
-

Beispiel Spiel {n}

-

Status: In Warteschlange

-

- Dies ist ein Platzhalter für ein Spiel in deiner Liste. Später kommen hier echte Daten per API. -

-
-
- -
-
- ); - })} -
-
+ - -
- ); +
+ + }/> + }/> + }/> + +
+ + + + + ); } export default App; \ No newline at end of file diff --git a/src/assets/hero.png b/src/assets/hero.png deleted file mode 100644 index cc51a3d..0000000 Binary files a/src/assets/hero.png and /dev/null differ diff --git a/src/assets/vite.svg b/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/src/assets/vue.svg b/src/assets/vue.svg deleted file mode 100644 index 770e9d3..0000000 --- a/src/assets/vue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 0000000..b899a46 --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,19 @@ +import i18n from 'i18next'; +import {initReactI18next} from 'react-i18next'; +import HttpBackend from 'i18next-http-backend'; + + +i18n.use(HttpBackend) + .use(initReactI18next) + .init({ + lng: 'de', + fallbackLng: 'en', + backend: { + loadPath: '/i18n/{{lng}}.json', + }, + interpolation: { + escapeValue: false + } + }); + +export default i18n; \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index 7003646..85bc3cc 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App.jsx'; +import './i18n'; import './style.css'; import '@mdi/font/css/materialdesignicons.css'; diff --git a/src/pages/ImprintPage.jsx b/src/pages/ImprintPage.jsx new file mode 100644 index 0000000..477298e --- /dev/null +++ b/src/pages/ImprintPage.jsx @@ -0,0 +1,28 @@ +import {useTranslation} from 'react-i18next'; + +export default function ImprintPage() { + const {t} = useTranslation(); + + return ( +
+
+

+ {t('IMPRINT.TITLE')} +

+
+

{t('IMPRINT.LEGAL')}

+

+ Andreas Dahm
+ Bgm.-Germeier-Str. 2
+ 85586 Poing
+ Deutschland +

+

+ {t('IMPRINT.CONTACT')} +

+

Email: andreas.dahm@gmail.com

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx new file mode 100644 index 0000000..6a6bafd --- /dev/null +++ b/src/pages/LandingPage.jsx @@ -0,0 +1,22 @@ +import {useTranslation} from 'react-i18next'; +import {Link} from 'react-router-dom'; + +export default function LandingPage() { + const {t} = useTranslation(); + + return ( +
+
+

+ {t('welcome')} +

+

+ Organize your pile of shame. Track, manage, and conquer your gaming backlog like never before. +

+ + Get Started + +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx new file mode 100644 index 0000000..3524584 --- /dev/null +++ b/src/pages/LoginPage.jsx @@ -0,0 +1,92 @@ +import {useTranslation} from 'react-i18next'; +import {useState} from 'react'; + +export default function LoginPage() { + const {t} = useTranslation(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [emailError, setEmailError] = useState(''); + const [passwordError, setPasswordError] = useState(''); + + const handleLogin = (e) => { + e.preventDefault(); + + setEmailError(''); + setPasswordError(''); + + let hasError = false; + + if (!email.includes('@') && !email.includes('.')) { + setEmailError(t('LOGIN.ERROR_EMAIL')); + hasError = true; + } + + if (password.trim() === '') { + setPasswordError(t('LOGIN.ERROR_PASSWORD')); + hasError = true; + } + + // Stop execution if form inputs are invalid + if (hasError) { + return; + } + + // Proceed with API authentication + console.log('Login attempt with:', email); + }; + + return ( +
+
+

+ {t('LOGIN.TITLE')} +

+ +
+
+ + { + setEmail(e.target.value); + }} + className={`w-full bg-slate-50 dark:bg-slate-950 border ${emailError ? 'border-red-500' : 'border-slate-200 dark:border-slate-800'} rounded-lg px-4 py-3 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500`} + placeholder="player@tobeplayed.com" + /> + {emailError && ( +

+ {emailError} +

+ )} +
+
+ + { + setPassword(e.target.value); + }} + className={`w-full bg-slate-50 dark:bg-slate-950 border ${passwordError ? 'border-red-500' : 'border-slate-200 dark:border-slate-800'} rounded-lg px-4 py-3 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500`} + placeholder="••••••••" + /> + {passwordError && ( +

+ {passwordError} +

+ )} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/style.css b/src/style.css index a461c50..ffc5adb 100644 --- a/src/style.css +++ b/src/style.css @@ -1 +1,3 @@ -@import "tailwindcss"; \ No newline at end of file +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); \ No newline at end of file