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 (
-
-
-
-
- -
- Get started by editing{" "}
-
- src/app/page.tsx
-
- .
-
- -
- Save and see your changes instantly.
-
-
-
-
-
-
-
+ <>
+
+ >
);
}
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 (
+
+

+
+ );
+}
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