diff --git a/next.config.ts b/next.config.ts index f7b60bb..37c5cdc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,10 @@ import type { NextConfig } from "next"; +import createNextIntlPlugin from 'next-intl/plugin'; const nextConfig: NextConfig = { output: 'standalone', /* config options here */ }; -export default nextConfig; +const withNextIntl = createNextIntlPlugin(); +export default withNextIntl(nextConfig); diff --git a/package.json b/package.json index ac6b286..e977519 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,25 @@ "lint": "next lint" }, "dependencies": { + "deepmerge": "^4.3.1", + "framer-motion": "^12.23.12", + "next": "15.4.6", + "next-intl": "^4.3.5", + "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.4.6" + "react-rnd": "^10.5.2" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.4.6", - "@eslint/eslintrc": "^3" + "eslint-plugin-react-hooks": "^5.2.0", + "tailwindcss": "^4", + "typescript": "^5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fee55ba..386395c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,30 @@ importers: .: dependencies: + deepmerge: + specifier: ^4.3.1 + version: 4.3.1 + framer-motion: + specifier: ^12.23.12 + version: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: specifier: 15.4.6 version: 15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next-intl: + specifier: ^4.3.5 + version: 4.3.5(next@15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.2) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: 19.1.0 version: 19.1.0 react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + react-rnd: + specifier: ^10.5.2 + version: 10.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) devDependencies: '@eslint/eslintrc': specifier: ^3 @@ -39,6 +54,9 @@ importers: eslint-config-next: specifier: 15.4.6 version: 15.4.6(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.2) + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.33.0(jiti@2.5.1)) tailwindcss: specifier: ^4 version: 4.1.11 @@ -103,6 +121,24 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@formatjs/ecma402-abstract@2.3.4': + resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==} + + '@formatjs/fast-memoize@2.2.7': + resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==} + + '@formatjs/icu-messageformat-parser@2.11.2': + resolution: {integrity: sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==} + + '@formatjs/icu-skeleton-parser@1.8.14': + resolution: {integrity: sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==} + + '@formatjs/intl-localematcher@0.5.10': + resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==} + + '@formatjs/intl-localematcher@0.6.1': + resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -341,6 +377,9 @@ packages: '@rushstack/eslint-patch@1.12.0': resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -727,6 +766,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -783,9 +826,16 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -1016,6 +1066,20 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + framer-motion@12.23.12: + resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1114,6 +1178,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + intl-messageformat@10.7.16: + resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1385,6 +1452,12 @@ packages: engines: {node: '>=10'} hasBin: true + motion-dom@12.23.12: + resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1401,6 +1474,26 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-intl@4.3.5: + resolution: {integrity: sha512-tT3SltfpPOCAQ9kVNr+8t6FUtVf8G0WFlJcVc8zj4WCMfuF8XFk4gZCN/MtjgDgkUISw5aKamOClJB4EsV95WQ==} + peerDependencies: + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@15.4.6: resolution: {integrity: sha512-us++E/Q80/8+UekzB3SAGs71AlLDsadpFMXVNM/uQ0BMwsh9m3mr0UNQIfjKed8vpWXsASe+Qifrnu1oLIcKEQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -1522,14 +1615,32 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + re-resizable@6.11.2: + resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==} + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: react: ^19.1.0 + react-draggable@4.4.6: + resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-rnd@10.5.2: + resolution: {integrity: sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==} + peerDependencies: + react: '>=16.3.0' + react-dom: '>=16.3.0' + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -1723,6 +1834,9 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1764,6 +1878,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-intl@4.3.5: + resolution: {integrity: sha512-qyL1TZNesVbzj/75ZbYsi+xzNSiFqp5rIVsiAN0JT8rPMSjX0/3KQz76aJIrngI1/wIQdVYFVdImWh5yAv+dWA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -1866,6 +1985,36 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 + '@formatjs/ecma402-abstract@2.3.4': + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/intl-localematcher': 0.6.1 + decimal.js: 10.6.0 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.7': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.11.2': + dependencies: + '@formatjs/ecma402-abstract': 2.3.4 + '@formatjs/icu-skeleton-parser': 1.8.14 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.14': + dependencies: + '@formatjs/ecma402-abstract': 2.3.4 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.5.10': + dependencies: + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.6.1': + dependencies: + tslib: 2.8.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -2038,6 +2187,8 @@ snapshots: '@rushstack/eslint-patch@1.12.0': {} + '@schummar/icu-type-parser@1.21.5': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -2434,6 +2585,8 @@ snapshots: client-only@0.0.1: {} + clsx@1.2.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2490,8 +2643,12 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} + deepmerge@4.3.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -2877,6 +3034,15 @@ snapshots: dependencies: is-callable: 1.2.7 + framer-motion@12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + motion-dom: 12.23.12 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -2978,6 +3144,13 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + intl-messageformat@10.7.16: + dependencies: + '@formatjs/ecma402-abstract': 2.3.4 + '@formatjs/fast-memoize': 2.2.7 + '@formatjs/icu-messageformat-parser': 2.11.2 + tslib: 2.8.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -3231,6 +3404,12 @@ snapshots: mkdirp@3.0.1: {} + motion-dom@12.23.12: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -3239,6 +3418,23 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + + next-intl@4.3.5(next@15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.9.2): + dependencies: + '@formatjs/intl-localematcher': 0.5.10 + negotiator: 1.0.0 + next: 15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + use-intl: 4.3.5(react@19.1.0) + optionalDependencies: + typescript: 5.9.2 + + next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + next@15.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.4.6 @@ -3369,13 +3565,33 @@ snapshots: queue-microtask@1.2.3: {} + re-resizable@6.11.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 scheduler: 0.26.0 + react-draggable@4.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + clsx: 1.2.1 + prop-types: 15.8.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-is@16.13.1: {} + react-rnd@10.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + re-resizable: 6.11.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-draggable: 4.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + tslib: 2.6.2 + react@19.1.0: {} reflect.getprototypeof@1.0.10: @@ -3643,6 +3859,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@2.6.2: {} + tslib@2.8.1: {} type-check@0.4.0: @@ -3721,6 +3939,13 @@ snapshots: dependencies: punycode: 2.3.1 + use-intl@4.3.5(react@19.1.0): + dependencies: + '@formatjs/fast-memoize': 2.2.7 + '@schummar/icu-type-parser': 1.21.5 + intl-messageformat: 10.7.16 + react: 19.1.0 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..2cd8e79 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,13 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import config from "@/config"; import "./globals.css"; +import { DeviceProvider } from "@/contexts/device"; +import {NextIntlClientProvider} from 'next-intl'; +import {getLocale} from 'next-intl/server'; + + const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], @@ -13,21 +19,29 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: config.title, + description: config.description, + icons: { + icon: config.icon, + }, }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const locale = await getLocale(); return ( - + - {children} + + + {children} + + ); diff --git a/src/app/page.tsx b/src/app/page.tsx index a932894..0971c71 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,103 +1,9 @@ -import Image from "next/image"; +import Desktop from "@/components/desktop"; export default function Home() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
- -
- - Vercel logomark - Deploy now - - - Read our docs - -
-
- -
+ <> + + ); } diff --git a/src/components/desktop/Background.tsx b/src/components/desktop/Background.tsx new file mode 100644 index 0000000..7f280a3 --- /dev/null +++ b/src/components/desktop/Background.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useDevice } from "@/contexts/device"; +import { useEffect, useState } from "react"; + +export default function Background() { + const { background } = useDevice(); + const [isLoaded, setIsLoaded] = useState(false); + + // 只在客户端渲染,避免水合错误 + useEffect(() => { + setIsLoaded(true); + }, []); + + if (!isLoaded) { + return null; // 服务端渲染时不显示任何内容 + } + + console.log("Background component rendered with background:", background); + + return ( +
+ ); +} \ No newline at end of file diff --git a/src/components/desktop/MobileDesktop.tsx b/src/components/desktop/MobileDesktop.tsx new file mode 100644 index 0000000..598ddea --- /dev/null +++ b/src/components/desktop/MobileDesktop.tsx @@ -0,0 +1,7 @@ +export default function MobileDesktop() { + return ( +
+

Mobile Desktop Component

+
+ ); +} \ No newline at end of file diff --git a/src/components/desktop/PCDesktop.tsx b/src/components/desktop/PCDesktop.tsx new file mode 100644 index 0000000..5075372 --- /dev/null +++ b/src/components/desktop/PCDesktop.tsx @@ -0,0 +1,22 @@ +import Content from "./content"; +import Dock from "./dock"; +import TopBar from "./topbar"; + +export default function PCDesktop() { + + return ( +
+
+ +
+
+ +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/desktop/content/index.tsx b/src/components/desktop/content/index.tsx new file mode 100644 index 0000000..488a0fa --- /dev/null +++ b/src/components/desktop/content/index.tsx @@ -0,0 +1,8 @@ +export default function Content() { + return ( +
+

Desktop Content Component

+ {/* Other content can be added here */} +
+ ); +} \ No newline at end of file diff --git a/src/components/desktop/dock/index.tsx b/src/components/desktop/dock/index.tsx new file mode 100644 index 0000000..c2075d8 --- /dev/null +++ b/src/components/desktop/dock/index.tsx @@ -0,0 +1,9 @@ +export default function Dock() { + return ( +
+ Dock Component +
+ ); +} \ No newline at end of file diff --git a/src/components/desktop/index.tsx b/src/components/desktop/index.tsx new file mode 100644 index 0000000..6ad7237 --- /dev/null +++ b/src/components/desktop/index.tsx @@ -0,0 +1,14 @@ +import PCDesktop from "./PCDesktop"; +import MobileDesktop from "./MobileDesktop"; +import { WindowsManagerProvider } from "@/contexts/windows"; +import Background from "./Background"; + +export default function Desktop() { + return ( + + + + + + ); +} \ No newline at end of file diff --git a/src/components/desktop/topbar/index.tsx b/src/components/desktop/topbar/index.tsx new file mode 100644 index 0000000..b1d59bd --- /dev/null +++ b/src/components/desktop/topbar/index.tsx @@ -0,0 +1,31 @@ +"use client"; + +import Datetime from "./widgets/Datetime"; +import Icon from "./widgets/Icon"; +import WindowTitle from "./widgets/WindowTitle"; + +function TopBarLeftMenu() { + return ( +
+ + +
+ ); +} + +function TopBarRightMenu() { + return ( +
+ +
+ ); +} + +export default function TopBar() { + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/desktop/topbar/widgets/Datetime.tsx b/src/components/desktop/topbar/widgets/Datetime.tsx new file mode 100644 index 0000000..747a184 --- /dev/null +++ b/src/components/desktop/topbar/widgets/Datetime.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import { useLocale, useTranslations } from 'next-intl'; + +export default function Datetime() { + const [time, setTime] = useState(null); + const locale = useLocale(); + const t = useTranslations(); + + useEffect(() => { + setTime(new Date()); + const timer = setInterval(() => { + setTime(new Date()); + }, 1000); + return () => clearInterval(timer); + }, []); + + + const formatTime = (date: Date) => { + return date.toLocaleTimeString("zh-CN", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + }); + }; + + const formatDate = (date: Date) => { + return date.toLocaleDateString(locale, { + month: "short", + day: "numeric", + weekday: "short", + }); + }; + + if (!time) return null; + + return ( +
+ + {formatDate(time)} + + + + {formatTime(time)} + +
+ ); +} diff --git a/src/components/desktop/topbar/widgets/Icon.tsx b/src/components/desktop/topbar/widgets/Icon.tsx new file mode 100644 index 0000000..7968a2b --- /dev/null +++ b/src/components/desktop/topbar/widgets/Icon.tsx @@ -0,0 +1,9 @@ +import config from "@/config"; + +export default function Icon() { + return ( +
+ Icon +
+ ); +} diff --git a/src/components/desktop/topbar/widgets/WindowTitle.tsx b/src/components/desktop/topbar/widgets/WindowTitle.tsx new file mode 100644 index 0000000..e6a5ba6 --- /dev/null +++ b/src/components/desktop/topbar/widgets/WindowTitle.tsx @@ -0,0 +1,16 @@ +import { useWindowsManager } from "@/contexts/windows"; +import { useEffect, useState } from "react"; + +export default function WindowTitle() { + const { getTopWindow } = useWindowsManager(); + const [title, setTitle] = useState(""); + useEffect(() => { + setTitle(getTopWindow()?.title ?? ""); + }, [getTopWindow]); + + return ( +
+ {title} +
+ ); +} diff --git a/src/components/windows/BaseWindow.tsx b/src/components/windows/BaseWindow.tsx new file mode 100644 index 0000000..9265611 --- /dev/null +++ b/src/components/windows/BaseWindow.tsx @@ -0,0 +1,288 @@ +"use client"; +import React, { useState, useEffect, useRef } from "react"; +import { Rnd, RndDragCallback, RndResizeCallback } from "react-rnd"; +import type { Rnd as RndType } from "react-rnd"; +import { useWindowsManager } from "@/contexts/windows"; +import { motion, AnimatePresence } from "framer-motion"; +import { useTranslations } from "next-intl"; + +export interface BaseWindowProps { + id?: string; + x?: number; + y?: number; + width?: number; + height?: number; + minWidth?: number; + minHeight?: number; + zIndex?: number; + draggable?: boolean; + resizable?: boolean; + maximized?: boolean; + visible?: boolean; + onDragStart?: RndDragCallback; + onDragStop?: RndDragCallback; + onResizeStop?: RndResizeCallback; + onClick?: React.MouseEventHandler; + dockHeight?: number; // 可选的停靠栏高度 + windowMargin?: number; // 窗口边距 + dragHandleClassName?: string; + children?: React.ReactNode; +} + +interface PreMaximizeState { + size: { width: number; height: number }; + position: { x: number; y: number }; + mouseOffset: { x: number; y: number }; +} + +export const BaseWindow: React.FC = ({ + id, + x = 100, + y = 100, + width = 400, + height = 300, + minWidth = 220, + minHeight = 120, + zIndex = 100, + draggable = true, + resizable = true, + maximized = false, + visible = true, + onDragStart, + onDragStop, + onResizeStop, + onClick, + dragHandleClassName, + dockHeight = 0, // 默认停靠栏顶部绝对高度 + windowMargin = 0, // 窗口边距 + children, +}) => { + const windowsManager = useWindowsManager?.(); + const win = + id && windowsManager + ? windowsManager.windows.find((w) => w.id === id) + : undefined; + const [position, setPosition] = useState({ x, y }); + const [size, setSize] = useState({ width, height }); + const [isMobile, setIsMobile] = useState(false); + const t = useTranslations('HomePage'); + + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth < 768); + }; + window.addEventListener("resize", handleResize); + handleResize(); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + useEffect(() => { + if (!win) setPosition({ x, y }); + }, [x, y, win]); + useEffect(() => { + if (!win) setSize({ width, height }); + }, [width, height, win]); + + const [shouldRender, setShouldRender] = useState(visible); + + useEffect(() => { + if (visible) { + setShouldRender(true); + } else { + const timer = setTimeout(() => setShouldRender(false), 250); + return () => clearTimeout(timer); + } + }, [visible]); + + const maximizedStyle = + (win?.maximized ?? maximized) + ? { + x: windowMargin, + y: windowMargin, + width: window.innerWidth - windowMargin * 2, + height: window.innerHeight - windowMargin * 2 - dockHeight, + } + : { + x: win?.position?.x ?? position.x, + y: win?.position?.y ?? position.y, + width: win?.size?.width ?? size.width, + height: win?.size?.height ?? size.height, + }; + + const [showMaximizeHint, setShowMaximizeHint] = useState(false); + const [preMaximize, setPreMaximize] = useState(null); + const rndRef = useRef(null); + const handleDrag: RndDragCallback = (_e, d) => { + if (win?.maximized ?? maximized) return; + if (d.y <= windowMargin) { + setShowMaximizeHint(true); + } else { + setShowMaximizeHint(false); + } + }; + + const handleDragStop: RndDragCallback = (e, d) => { + if (showMaximizeHint) { + setShowMaximizeHint(false); + if (id && windowsManager) { + windowsManager.updateWindow(id, { maximized: true }); + } + return; + } + if (id && windowsManager) { + windowsManager.updateWindow(id, { position: { x: d.x, y: d.y } }); + } else { + setPosition({ x: d.x, y: d.y }); + } + onDragStop?.(e, d); + }; + const handleDragStart: RndDragCallback = (e, d) => { + if (win?.maximized ?? maximized) { + const mouseX = (e as MouseEvent).clientX; + const mouseY = (e as MouseEvent).clientY; + const widthVal = win?.size?.width ?? size.width; + const heightVal = win?.size?.height ?? size.height; + setPreMaximize({ + size: { width: widthVal, height: heightVal }, + position: { x: d.x, y: d.y }, + mouseOffset: { + x: mouseX - (maximizedStyle.x ?? 0), + y: mouseY - (maximizedStyle.y ?? 0), + }, + }); + if (id && windowsManager) { + windowsManager.updateWindow(id, { maximized: false }); + } + } + onDragStart?.(e, d); + }; + + useEffect(() => { + if (!win?.maximized && preMaximize && rndRef.current) { + const mouse = preMaximize.mouseOffset; + const newX = Math.max( + 0, + window.innerWidth / 2 - preMaximize.size.width / 2, + ); + const newY = Math.max( + windowMargin, + mouse.y - preMaximize.size.height / 2, + ); + rndRef.current.updatePosition({ x: newX, y: newY }); + rndRef.current.updateSize({ + width: preMaximize.size.width, + height: preMaximize.size.height, + }); + setPreMaximize(null); + if (id && windowsManager) { + windowsManager.updateWindow(id, { + position: { x: newX, y: newY }, + size: { + width: preMaximize.size.width, + height: preMaximize.size.height, + }, + }); + } else { + setPosition({ x: newX, y: newY }); + setSize({ + width: preMaximize.size.width, + height: preMaximize.size.height, + }); + } + } + }, [ + win?.maximized, + preMaximize, + id, + windowsManager, + size.width, + size.height, + windowMargin, + ]); + if (!shouldRender) return null; + // 移动端下直接全屏 + const rndSize = isMobile + ? { width: "100vw", height: "100vh" } + : { width: maximizedStyle.width, height: maximizedStyle.height }; + const rndPosition = isMobile + ? { x: 0, y: 0 } + : { x: maximizedStyle.x, y: maximizedStyle.y }; + + return ( + + {visible && ( + { + if (id && windowsManager) { + windowsManager.placeOnTop(id); + } + }} + onResizeStop={(e, dir, ref, delta, pos) => { + if (id && windowsManager) { + windowsManager.updateWindow(id, { + size: { + width: parseInt(ref.style.width, 10), + height: parseInt(ref.style.height, 10), + }, + position: { x: pos.x, y: pos.y }, + }); + } else { + setSize({ + width: parseInt(ref.style.width, 10), + height: parseInt(ref.style.height, 10), + }); + setPosition({ x: pos.x, y: pos.y }); + } + onResizeStop?.(e, dir, ref, delta, pos); + }} + onClick={onClick} + > + + {showMaximizeHint && ( +
+ {t("ui.releaseToMaximize")} +
+ )} + {children} +
+
+ )} +
+ ); +}; + +export default BaseWindow; diff --git a/src/components/windows/MacOSWindow.tsx b/src/components/windows/MacOSWindow.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..5910d0e --- /dev/null +++ b/src/config.ts @@ -0,0 +1,5 @@ +export default { + title: process.env.NEXT_PUBLIC_APP_TITLE || "Snowykami OS", + description: process.env.NEXT_PUBLIC_APP_DESCRIPTION || "A modern OS built with Next.js", + icon: process.env.NEXT_PUBLIC_APP_ICON || "https://cdn.liteyuki.org/snowykami/avatar_alpha.png" +} \ No newline at end of file diff --git a/src/contexts/device.tsx b/src/contexts/device.tsx new file mode 100644 index 0000000..a3d57f7 --- /dev/null +++ b/src/contexts/device.tsx @@ -0,0 +1,93 @@ +"use client" + +import { createContext, useContext, useEffect, useState } from "react"; + +const THEME_STORAGE_KEY = "theme"; + +const BACKGROUND_STORAGE_KEY = "background"; + +type Theme = "light" | "dark" | "system"; + +interface DeviceContextProps { + isMobile: boolean; + theme: Theme; + setTheme: (theme: Theme) => void; + background: string; + setBackground: (background: string) => void; +} + +const DeviceContext = createContext({ + background: 'https://cdn.liteyuki.org/blog/background.png', + isMobile: false, + theme: "system", + setTheme: () => { }, + setBackground: () => { }, +}); + +export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isMobile, setIsMobile] = useState(false); + const [theme, setTheme] = useState("system"); + const [background, setBackground] = useState('https://cdn.liteyuki.org/blog/background.png'); + + // 挂载后读取 localStorage + useEffect(() => { + if (typeof window !== "undefined") { + const savedTheme = window.localStorage.getItem(THEME_STORAGE_KEY) as Theme; + if (savedTheme) setTheme(savedTheme); + const savedBg = window.localStorage.getItem(BACKGROUND_STORAGE_KEY); + if (savedBg) setBackground(savedBg); + } + }, []); + + // 监听系统主题变化 + useEffect(() => { + if (theme === "system") { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = () => { + document.documentElement.classList.toggle("dark", media.matches); + }; + handler(); + media.addEventListener("change", handler); + return () => media.removeEventListener("change", handler); + } + }, [theme]); + + // 监听配置主题变化 + useEffect(() => { + if (typeof window !== "undefined") { + window.localStorage.setItem("theme", theme); + if (theme !== "system") { + document.documentElement.classList.toggle("dark", theme === "dark"); + } + } + }, [theme]); + + // 监听背景变化并持久化 + useEffect(() => { + if (typeof window !== "undefined") { + window.localStorage.setItem(BACKGROUND_STORAGE_KEY, background); + } + }, [background]); + + // 响应式判断 isMobile + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 768); + checkMobile(); + window.addEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); + }, []); + + return ( + + {children} + + ); +}; + +export const useDevice = () => { + const context = useContext(DeviceContext); + if (!context) { + throw new Error("useDevice must be used within a DeviceProvider"); + } + return context; +}; \ No newline at end of file diff --git a/src/contexts/windows.tsx b/src/contexts/windows.tsx new file mode 100644 index 0000000..8fad728 --- /dev/null +++ b/src/contexts/windows.tsx @@ -0,0 +1,183 @@ +"use client" + +import { WindowProps } from "@/types/window"; +import { createContext, useCallback, useContext, useEffect, useState } from "react"; + +const WINDOWS_STORAGE_KEY = "windows"; + +interface WindowsManagerContextProps { + windows: WindowProps[]; + // window operations + openWindow: (id: string, props: Partial) => void; + closeWindow: (id: string) => void; + minimizeWindow: (id: string) => void; + placeOnTop: (id: string) => void; + updateWindow: (id: string, props: Partial) => void; + // get and global + getWindowById: (id: string) => WindowProps | undefined; + getTopWindow: () => WindowProps | undefined; + resetWindowsOfLocalStorage?: () => void; +} + +const WindowManagerContext = createContext( + null +); + +export const WindowsManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + // 彻底关闭窗口(移除) + const closeWindow = useCallback((id: string) => { + setWindows(ws => ws.filter(w => w.id !== id)); + }, []); + + // 最小化窗口(只设置 visible=false,不移除) + const minimizeWindow = useCallback((id: string) => { + setWindows(ws => ws.map(w => w.id === id ? { ...w, visible: false } : w)); + }, []); + const [windows, setWindows] = useState(() => { + try { + const saved = localStorage.getItem(WINDOWS_STORAGE_KEY); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); + + useEffect(() => { + localStorage.setItem(WINDOWS_STORAGE_KEY, JSON.stringify(windows)); + }, [windows]); + + + const openWindow = useCallback( + (id: string, initial: Partial = {}) => { + setWindows((ws) => { + const exist = ws.find((w) => w.id === id); + if (exist) { + return ws.map((w) => + w.id === id + ? { + ...w, + visible: true, + minimized: false, + ...initial, + } + : w, + ); + } + if (ws.find((w) => w.id === id)) return ws; + const maxZ = ws.reduce((max, w) => Math.max(max, w.zIndex), 100); + const size = initial.size ?? { width: 400, height: 300 }; + const position = + initial.position ?? calculateNewWindowPosition(ws.length, size); + return [ + ...ws, + { + id, + title: initial.title ?? "", + visible: true, + zIndex: maxZ + 1, + maximized: false, + minimized: false, + position, + size, + ...initial, + }, + ]; + }); + }, + [], + ); + + const placeOnTop = useCallback( + (id: string) => { + setWindows((ws) => { + const win = ws.find((w) => w.id === id); + if (!win) return ws; + + const maxZ = ws.reduce((max, w) => Math.max(max, w.zIndex), 100); + return ws.map((w) => + w.id === id + ? { ...w, zIndex: maxZ + 1 } + : w, + ); + }); + }, + [], + ); + + const updateWindow = useCallback( + (id: string, props: Partial) => { + setWindows((ws) => { + const win = ws.find((w) => w.id === id); + if (!win) return ws; + return ws.map((w) => + w.id === id ? { ...w, ...props } : w, + ); + }); + }, + [], + ); + + const getWindowById = useCallback( + (id: string) => { + return windows.find((w) => w.id === id); + }, + [windows], + ); + + const getTopWindow = useCallback(() => { + if (windows.length === 0) return undefined; + return windows.reduce((top, w) => (w.zIndex > top.zIndex ? w : top), windows[0]); + }, [windows]); + + const resetWindowsOfLocalStorage = useCallback(() => { + if (typeof window !== "undefined") { + window.localStorage.removeItem("windows"); + } + }, []); + + return ( + + {children} + + ); +}; + +export const useWindowsManager = () => { + const ctx = useContext(WindowManagerContext); + if (!ctx) + throw new Error( + "useWindowsManager must be used within WindowsManagerProvider", + ); + return ctx; +}; + +const calculateNewWindowPosition = ( + count: number, + size = { width: 400, height: 300 }, +) => { + // 屏幕中心 + const centerX = Math.max(window.innerWidth / 2 - size.width / 2, 0); + const centerY = Math.max(window.innerHeight / 2 - size.height / 2, 0); + const offset = 20; + let x = centerX + offset * (count % 6); + let y = centerY + offset * (count % 6); + // 限制窗口不超出右下边界 + x = Math.min(x, window.innerWidth - size.width); + y = Math.min(y, window.innerHeight - size.height); + // 限制窗口不超出左上边界 + x = Math.max(x, 0); + y = Math.max(y, 0); + return { x, y }; +}; \ No newline at end of file diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 0000000..cf12beb --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,37 @@ +import { getRequestConfig } from 'next-intl/server'; +import { cookies } from 'next/headers'; +import deepmerge from 'deepmerge'; + +export default getRequestConfig(async () => { + const locales = await getUserLocales(); + const messages = await Promise.all( + locales.map(async (locale) => { + try { + return (await import(`@/locales/${locale}.json`)).default; + } catch (err) { + console.debug(`Failed to load locale ${locale}: ${err}`); + return {}; + } + }) + ).then((msgs) => msgs.reduce((acc, msg) => deepmerge(acc, msg), {})); + return { + locale: locales[0], + messages + }; +}); + +export async function getUserLocales(): Promise { + const defaultLocales = ["zh-CN", "zh", "en-US", "en"]; + const cookieStore = await cookies(); + const languageInCookie = cookieStore.get('language')?.value; + let locales: string[] = []; + if (languageInCookie) { + locales.push(languageInCookie); + locales.push(languageInCookie.split('-')[0]); + } + locales.push(...defaultLocales); + + // 去重,保留顺序 + locales = Array.from(new Set(locales)); + return locales; +} \ No newline at end of file diff --git a/src/types/window.ts b/src/types/window.ts new file mode 100644 index 0000000..6296f78 --- /dev/null +++ b/src/types/window.ts @@ -0,0 +1,18 @@ +export interface WindowProps { + id: string; + title: string; + appId?: string; + visible: boolean; + zIndex: number; + maximized: boolean; + minimized: boolean; + position: { x: number; y: number }; + size: { width: number; height: number }; + showClose?: boolean; + showMinimize?: boolean; + showMaximize?: boolean; + className?: string; + onClose?: () => void; + onMinimize?: () => void; + onMaximize?: () => void; +} \ No newline at end of file