Rewrite in React

This commit is contained in:
2026-05-19 21:25:23 +03:00
parent b8b5c12032
commit aaa637f188
46 changed files with 1598 additions and 1219 deletions

482
bun.lock Normal file
View File

@@ -0,0 +1,482 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "tietokonepaja-fi",
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1",
},
"devDependencies": {
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.18.11",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.37.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"jiti": "^2.6.1",
"npm-run-all2": "^8.0.4",
"prettier": "3.6.2",
"typescript": "~5.9.0",
"vite": "^7.2.2",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
"@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
"@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.2", "", { "os": "android", "cpu": "arm" }, "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.2", "", { "os": "android", "cpu": "arm64" }, "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.2", "", { "os": "linux", "cpu": "arm" }, "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.2", "", { "os": "linux", "cpu": "arm" }, "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.2", "", { "os": "linux", "cpu": "none" }, "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.2", "", { "os": "linux", "cpu": "none" }, "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.2", "", { "os": "linux", "cpu": "none" }, "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.2", "", { "os": "linux", "cpu": "x64" }, "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.2", "", { "os": "none", "cpu": "arm64" }, "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.2", "", { "os": "win32", "cpu": "x64" }, "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.2", "", { "os": "win32", "cpu": "x64" }, "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA=="],
"@tsconfig/node22": ["@tsconfig/node22@22.0.3", "", {}, "sha512-9UTUkYWI58+MiZhwcQWx2TNZbzkGRss9SCyjrSYeqkMIDYq8jv2FSRknrLOjsRmcYFUsYj79m/bgQYSD/yiRxw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="],
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.28", "", { "bin": "dist/cli.js" }, "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.28.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", "electron-to-chromium": "^1.5.249", "node-releases": "^2.0.27", "update-browserslist-db": "^1.1.4" }, "bin": "cli.js" }, "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001754", "", {}, "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.253", "", {}, "sha512-O0tpQ/35rrgdiGQ0/OFWhy1itmd9A6TY9uQzlqj3hKSu/aYpe7UIn5d7CU2N9myH6biZiWF3VMZVuup8pw5U9w=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
"jiti": ["jiti@2.6.1", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@4.0.0", "", {}, "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"npm-normalize-package-bin": ["npm-normalize-package-bin@4.0.0", "", {}, "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w=="],
"npm-run-all2": ["npm-run-all2@8.0.4", "", { "dependencies": { "ansi-styles": "^6.2.1", "cross-spawn": "^7.0.6", "memorystream": "^0.3.1", "picomatch": "^4.0.2", "pidtree": "^0.6.0", "read-package-json-fast": "^4.0.0", "shell-quote": "^1.7.3", "which": "^5.0.0" }, "bin": { "npm-run-all": "bin/npm-run-all/index.js", "npm-run-all2": "bin/npm-run-all/index.js", "run-p": "bin/run-p/index.js", "run-s": "bin/run-s/index.js" } }, "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pidtree": ["pidtree@0.6.0", "", { "bin": "bin/pidtree.js" }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.6.2", "", { "bin": "bin/prettier.cjs" }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-router": ["react-router@7.15.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A=="],
"react-router-dom": ["react-router-dom@7.15.1", "", { "dependencies": { "react-router": "7.15.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg=="],
"read-package-json-fast": ["read-package-json-fast@4.0.0", "", { "dependencies": { "json-parse-even-better-errors": "^4.0.0", "npm-normalize-package-bin": "^4.0.0" } }, "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.53.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.2", "@rollup/rollup-android-arm64": "4.53.2", "@rollup/rollup-darwin-arm64": "4.53.2", "@rollup/rollup-darwin-x64": "4.53.2", "@rollup/rollup-freebsd-arm64": "4.53.2", "@rollup/rollup-freebsd-x64": "4.53.2", "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", "@rollup/rollup-linux-arm-musleabihf": "4.53.2", "@rollup/rollup-linux-arm64-gnu": "4.53.2", "@rollup/rollup-linux-arm64-musl": "4.53.2", "@rollup/rollup-linux-loong64-gnu": "4.53.2", "@rollup/rollup-linux-ppc64-gnu": "4.53.2", "@rollup/rollup-linux-riscv64-gnu": "4.53.2", "@rollup/rollup-linux-riscv64-musl": "4.53.2", "@rollup/rollup-linux-s390x-gnu": "4.53.2", "@rollup/rollup-linux-x64-gnu": "4.53.2", "@rollup/rollup-linux-x64-musl": "4.53.2", "@rollup/rollup-openharmony-arm64": "4.53.2", "@rollup/rollup-win32-arm64-msvc": "4.53.2", "@rollup/rollup-win32-ia32-msvc": "4.53.2", "@rollup/rollup-win32-x64-gnu": "4.53.2", "@rollup/rollup-win32-x64-msvc": "4.53.2", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"vite": ["vite@7.2.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/config-array/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@eslint/eslintrc/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"tinyglobby/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"vite/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
}
}

View File

@@ -55,7 +55,7 @@
<meta name="theme-color" content="#2c3e50" /> <meta name="theme-color" content="#2c3e50" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="root"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -12,30 +12,28 @@
"preview": "vite preview", "preview": "vite preview",
"start": "vite preview --port 4174 --host", "start": "vite preview --port 4174 --host",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build", "type-check": "tsc --build",
"lint": "eslint . --fix --cache", "lint": "eslint . --fix --cache",
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.22", "react": "^19.1.0",
"vue-router": "^4.6.3" "react-dom": "^19.1.0",
"react-router-dom": "^7.6.1"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "^22.0.2", "@tsconfig/node22": "^22.0.2",
"@types/node": "^22.18.11", "@types/node": "^22.18.11",
"@vitejs/plugin-vue": "^6.0.1", "@types/react": "^19.1.4",
"@vitejs/plugin-vue-jsx": "^5.1.1", "@types/react-dom": "^19.1.5",
"@vue/eslint-config-prettier": "^10.2.0", "@vitejs/plugin-react": "^4.5.2",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.37.0", "eslint": "^9.37.0",
"eslint-plugin-vue": "~10.5.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"prettier": "3.6.2", "prettier": "3.6.2",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"vite": "^7.2.2", "vite": "^7.2.2"
"vite-plugin-vue-devtools": "^8.0.3",
"vue-tsc": "^3.1.1"
} }
} }

View File

@@ -1,54 +1,28 @@
<script setup lang="ts"> .layout {
import { RouterLink, RouterView } from 'vue-router' width: 100%;
import ThemeToggle from './components/ThemeToggle.vue' }
import LanguageToggle from './components/LanguageToggle.vue'
import LogoSvg from './components/icons/IconLogo.vue'
import { useLanguage } from './composables/useLanguage'
const { t } = useLanguage() @media (min-width: 1024px) {
</script> .layout {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}
<template> .header {
<ThemeToggle />
<LanguageToggle />
<header>
<div class="wrapper">
<div class="title-section">
<div class="greetings">
<div class="title-with-logo">
<LogoSvg alt="Tietokonepajan logo" class="logo" />
<h1>{{ t.siteTitle }}</h1>
</div>
<h3>{{ t.siteSubtitle }}</h3>
</div>
</div>
<nav>
<RouterLink to="/">{{ t.navHome }}</RouterLink>
<RouterLink to="/about">{{ t.navAbout }}</RouterLink>
<RouterLink to="/portfolio">{{ t.navPortfolio }}</RouterLink>
<RouterLink to="/contact">{{ t.navContact }}</RouterLink>
</nav>
</div>
</header>
<RouterView />
</template>
<style scoped>
header {
line-height: 1.5; line-height: 1.5;
max-height: 100vh; max-height: 100vh;
} }
.title-section { .titleSection {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: 1.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.title-with-logo { .titleWithLogo {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
@@ -81,7 +55,7 @@ header {
text-align: center; text-align: center;
} }
nav { .nav {
width: 100%; width: 100%;
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
@@ -89,38 +63,38 @@ nav {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
nav a.router-link-exact-active { .nav a:global(.active) {
color: var(--color-text); color: var(--color-text);
} }
nav a.router-link-exact-active:hover { .nav a:global(.active):hover {
background-color: transparent; background-color: transparent;
} }
nav a { .nav a {
display: inline-block; display: inline-block;
padding: 0 1rem; padding: 0 1rem;
border-left: 1px solid var(--color-border); border-left: 1px solid var(--color-border);
} }
nav a:first-of-type { .nav a:first-of-type {
border: 0; border: 0;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
header { .header {
display: flex; display: flex;
place-items: center; place-items: center;
padding-right: calc(var(--section-gap) / 2); padding-right: calc(var(--section-gap) / 2);
} }
.title-section { .titleSection {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
margin-bottom: 0; margin-bottom: 0;
} }
.title-with-logo { .titleWithLogo {
justify-content: flex-start; justify-content: flex-start;
flex-direction: row; flex-direction: row;
} }
@@ -137,19 +111,17 @@ nav a:first-of-type {
text-align: left; text-align: left;
} }
header .wrapper { .wrapper {
display: flex; display: flex;
place-items: flex-start; place-items: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
} }
nav { .nav {
text-align: left; text-align: left;
margin-left: -1rem; margin-left: -1rem;
font-size: 1rem; font-size: 1rem;
padding: 1rem 0; padding: 1rem 0;
margin-top: 1rem; margin-top: 1rem;
} }
} }
</style>

60
src/App.tsx Normal file
View File

@@ -0,0 +1,60 @@
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'
import ThemeToggle from './components/ThemeToggle'
import LanguageToggle from './components/LanguageToggle'
import LogoSvg from './components/icons/IconLogo'
import { useLanguage } from './contexts/LanguageContext'
import HomeView from './views/HomeView'
import InfoView from './views/InfoView'
import ContactView from './views/ContactView'
import PortfolioView from './views/PortfolioView'
import styles from './App.module.css'
function AppContent() {
const { t } = useLanguage()
return (
<>
<ThemeToggle />
<LanguageToggle />
<div className={styles.layout}>
<header className={styles.header}>
<div className={styles.wrapper}>
<div className={styles.titleSection}>
<div className={styles.greetings}>
<div className={styles.titleWithLogo}>
<LogoSvg className={styles.logo} />
<h1>{t.siteTitle}</h1>
</div>
<h3>{t.siteSubtitle}</h3>
</div>
</div>
<nav className={styles.nav}>
<NavLink to="/" end>
{t.navHome}
</NavLink>
<NavLink to="/about">{t.navAbout}</NavLink>
<NavLink to="/portfolio">{t.navPortfolio}</NavLink>
<NavLink to="/contact">{t.navContact}</NavLink>
</nav>
</div>
</header>
<Routes>
<Route path="/" element={<HomeView />} />
<Route path="/about" element={<InfoView />} />
<Route path="/portfolio" element={<PortfolioView />} />
<Route path="/contact" element={<ContactView />} />
</Routes>
</div>
</>
)
}
export default function App() {
return (
<BrowserRouter basename={import.meta.env.BASE_URL}>
<AppContent />
</BrowserRouter>
)
}

View File

@@ -1,6 +1,6 @@
@import './base.css'; @import './base.css';
#app { #root {
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
@@ -26,10 +26,4 @@ a {
display: flex; display: flex;
place-items: center; place-items: center;
} }
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
} }

View File

@@ -0,0 +1,30 @@
import ServiceItem from './ServiceItem'
import IconHomesite from './icons/IconHomesite'
import IconItSupport from './icons/IconItSupport'
import IconLinux from './icons/IconLinux'
import IconRepair from './icons/IconRepair'
import { useLanguage } from '../contexts/LanguageContext'
export default function HomeSection() {
const { t } = useLanguage()
return (
<>
<ServiceItem icon={<IconHomesite />} heading={t.service1Heading}>
{t.service1Text}
</ServiceItem>
<ServiceItem icon={<IconItSupport />} heading={t.service2Heading}>
{t.service2Text}
</ServiceItem>
<ServiceItem icon={<IconRepair />} heading={t.service3Heading}>
{t.service3Text}
</ServiceItem>
<ServiceItem icon={<IconLinux />} heading={t.service4Heading}>
{t.service4Text}
</ServiceItem>
</>
)
}

View File

@@ -1,44 +0,0 @@
<script setup lang="ts">
import ServiceItem from './ServiceItem.vue'
import IconHomesite from './icons/IconHomesite.vue'
import IconItSupport from './icons/IconItSupport.vue'
import IconLinux from './icons/IconLinux.vue'
import IconRepair from './icons/IconRepair.vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
</script>
<template>
<ServiceItem>
<template #icon>
<IconHomesite />
</template>
<template #heading>{{ t.service1Heading }}</template>
{{ t.service1Text }}
</ServiceItem>
<ServiceItem>
<template #icon>
<IconItSupport />
</template>
<template #heading>{{ t.service2Heading }}</template>
{{ t.service2Text }}
</ServiceItem>
<ServiceItem>
<template #icon>
<IconRepair />
</template>
<template #heading>{{ t.service3Heading }}</template>
{{ t.service3Text }}
</ServiceItem>
<ServiceItem>
<template #icon>
<IconLinux />
</template>
<template #heading>{{ t.service4Heading }}</template>
{{ t.service4Text }}
</ServiceItem>
</template>

View File

@@ -1,32 +1,4 @@
<template> .languagePicker {
<div class="language-picker">
<button
@click="setLanguage('fi')"
class="lang-btn"
:class="{ active: language === 'fi' }"
aria-label="Vaihda suomeksi"
>
FI
</button>
<button
@click="setLanguage('en')"
class="lang-btn"
:class="{ active: language === 'en' }"
aria-label="Switch to English"
>
EN
</button>
</div>
</template>
<script setup lang="ts">
import { useLanguage } from '@/composables/useLanguage'
const { language, setLanguage } = useLanguage()
</script>
<style scoped>
.language-picker {
position: fixed; position: fixed;
top: 20px; top: 20px;
right: 78px; right: 78px;
@@ -40,7 +12,7 @@ const { language, setLanguage } = useLanguage()
padding: 3px; padding: 3px;
} }
.lang-btn { .langBtn {
background: transparent; background: transparent;
border: none; border: none;
border-radius: 20px; border-radius: 20px;
@@ -57,17 +29,16 @@ const { language, setLanguage } = useLanguage()
opacity: 0.5; opacity: 0.5;
} }
.lang-btn:hover { .langBtn:hover {
opacity: 0.8; opacity: 0.8;
} }
.lang-btn.active { .langBtn.active {
background: var(--color-background-mute); background: var(--color-background-mute);
opacity: 1; opacity: 1;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
.lang-btn:active { .langBtn:active {
transform: scale(0.95); transform: scale(0.95);
} }
</style>

View File

@@ -0,0 +1,25 @@
import { useLanguage } from '../contexts/LanguageContext'
import styles from './LanguageToggle.module.css'
export default function LanguageToggle() {
const { language, setLanguage } = useLanguage()
return (
<div className={styles.languagePicker}>
<button
onClick={() => setLanguage('fi')}
className={`${styles.langBtn}${language === 'fi' ? ` ${styles.active}` : ''}`}
aria-label="Vaihda suomeksi"
>
FI
</button>
<button
onClick={() => setLanguage('en')}
className={`${styles.langBtn}${language === 'en' ? ` ${styles.active}` : ''}`}
aria-label="Switch to English"
>
EN
</button>
</div>
)
}

View File

@@ -1,18 +1,3 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item { .item {
margin-top: 2rem; margin-top: 2rem;
display: flex; display: flex;
@@ -24,18 +9,18 @@
margin-left: 1rem; margin-left: 1rem;
} }
i { .iconWrapper {
display: flex; display: flex;
place-items: center; place-items: center;
place-content: center; place-content: center;
width: 32px; width: 32px;
height: 32px; height: 32px;
padding: 5px; padding: 5px;
font-style: normal;
color: var(--color-text); color: var(--color-text);
} }
h3 { .details h3 {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 500; font-weight: 500;
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
@@ -48,7 +33,7 @@ h3 {
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2); padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
} }
i { .iconWrapper {
top: calc(50% - 25px); top: calc(50% - 25px);
left: -26px; left: -26px;
position: absolute; position: absolute;
@@ -59,7 +44,7 @@ h3 {
height: 50px; height: 50px;
} }
.item:before { .item::before {
content: ' '; content: ' ';
border-left: 1px solid var(--color-border); border-left: 1px solid var(--color-border);
position: absolute; position: absolute;
@@ -68,7 +53,7 @@ h3 {
height: calc(50% - 25px); height: calc(50% - 25px);
} }
.item:after { .item::after {
content: ' '; content: ' ';
border-left: 1px solid var(--color-border); border-left: 1px solid var(--color-border);
position: absolute; position: absolute;
@@ -77,12 +62,11 @@ h3 {
height: calc(50% - 25px); height: calc(50% - 25px);
} }
.item:first-of-type:before { .item:first-of-type::before {
display: none; display: none;
} }
.item:last-of-type:after { .item:last-of-type::after {
display: none; display: none;
} }
} }
</style>

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react'
import styles from './ServiceItem.module.css'
interface Props {
icon: ReactNode
heading: ReactNode
children: ReactNode
}
export default function ServiceItem({ icon, heading, children }: Props) {
return (
<div className={styles.item}>
<i className={styles.iconWrapper}>{icon}</i>
<div className={styles.details}>
<h3>{heading}</h3>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
.themeToggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.themeToggle:hover {
background: var(--color-background-mute);
border-color: var(--color-border-hover);
transform: scale(1.05);
}
.themeToggle:active {
transform: scale(0.95);
}
.icon {
width: 20px;
height: 20px;
}

View File

@@ -0,0 +1,35 @@
import { useTheme } from '../contexts/ThemeContext'
import styles from './ThemeToggle.module.css'
export default function ThemeToggle() {
const { isDark, toggleTheme } = useTheme()
return (
<button
onClick={toggleTheme}
className={styles.themeToggle}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title="Toggle theme"
>
{isDark ? (
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
{/* Sun icon */}
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
) : (
<svg className={styles.icon} viewBox="0 0 24 24" fill="none" stroke="currentColor">
{/* Moon icon */}
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
)
}

View File

@@ -1,128 +0,0 @@
<template>
<button
@click="toggleTheme"
class="theme-toggle"
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
title="Toggle theme"
>
<svg v-if="isDark" class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<!-- Sun icon -->
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
<svg v-else class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<!-- Moon icon -->
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
const isDark = ref(false)
const toggleTheme = () => {
isDark.value = !isDark.value
updateTheme()
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
const updateTheme = () => {
if (isDark.value) {
document.documentElement.classList.add('dark')
document.documentElement.classList.remove('light')
} else {
document.documentElement.classList.add('light')
document.documentElement.classList.remove('dark')
}
}
const initTheme = () => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
isDark.value = savedTheme === 'dark'
} else {
// Check system preference as fallback
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
updateTheme()
}
onMounted(() => {
initTheme()
})
// Watch for system theme changes when no preference is saved
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
isDark.value = e.matches
updateTheme()
}
})
</script>
<style scoped>
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.theme-toggle:hover {
background: var(--color-background-mute);
border-color: var(--color-border-hover);
transform: scale(1.05);
}
.theme-toggle:active {
transform: scale(0.95);
}
.icon {
width: 20px;
height: 20px;
color: var(--color-text);
stroke-width: 2;
transition: transform 0.3s ease;
}
.theme-toggle:hover .icon {
transform: rotate(15deg);
}
/* Ensure the button works well on mobile */
@media (max-width: 768px) {
.theme-toggle {
top: 15px;
right: 15px;
width: 44px;
height: 44px;
}
.icon {
width: 18px;
height: 18px;
}
}
</style>

View File

@@ -0,0 +1,31 @@
export default function IconEmail() {
return (
<svg
className="icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ height: 30, width: 30, flexShrink: 0 }}
>
<path
d="M4 7.00005L10.2 11.65C11.2667 12.45 12.7333 12.45 13.8 11.65L20 7"
stroke="var(--color-svg-stroke)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ transition: 'stroke 0.3s ease' }}
/>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="var(--color-svg-stroke)"
strokeWidth="2"
strokeLinecap="round"
style={{ transition: 'stroke 0.3s ease' }}
/>
</svg>
)
}

View File

@@ -1,35 +0,0 @@
<template>
<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4 7.00005L10.2 11.65C11.2667 12.45 12.7333 12.45 13.8 11.65L20 7"
stroke="#000000"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="#000000"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</template>
<style scoped>
.icon {
height: 30px;
width: 30px;
flex-shrink: 0;
}
.icon path,
.icon rect {
stroke: var(--color-svg-stroke);
transition: stroke 0.3s ease;
}
</style>

View File

@@ -0,0 +1,28 @@
export default function IconHomesite() {
return (
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: 32, height: 32 }}
>
<g>
<path
fill="var(--color-svg-fill)"
d="M0,48v416c0,26.508,21.492,48,48,48h416c26.508,0,48-21.492,48-48V48c0-26.508-21.492-48-48-48H48
C21.492,0,0,21.492,0,48z M86.336,54c0,10.492-8.508,19-19,19c-10.492,0-19-8.508-19-19s8.508-19,19-19
C77.828,35,86.336,43.508,86.336,54z M156.836,54c0,10.492-8.508,19-19,19c-10.492,0-19-8.508-19-19s8.508-19,19-19
C148.328,35,156.836,43.508,156.836,54z M227.336,54c0,10.492-8.508,19-19,19c-10.492,0-19-8.508-19-19s8.508-19,19-19
C218.828,35,227.336,43.508,227.336,54z M40,104h432v360c0,4.406-3.586,8-8,8H48c-4.414,0-8-3.594-8-8V104z"
/>
<rect x="264" y="192" fill="var(--color-svg-fill)" width="152" height="32" />
<rect x="88" y="352" fill="var(--color-svg-fill)" width="328" height="32" />
<rect x="88" y="192" fill="var(--color-svg-fill)" width="120" height="120" />
<polygon
fill="var(--color-svg-fill)"
points="282.958,304 264,304 264,272 416,272 416,304 298.958,304"
/>
</g>
</svg>
)
}

View File

@@ -1,31 +0,0 @@
<template>
<svg
version="1.1"
id="_x32_"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512"
xml:space="preserve"
>
<g>
<path
class="st0"
d="M0,48v416c0,26.508,21.492,48,48,48h416c26.508,0,48-21.492,48-48V48c0-26.508-21.492-48-48-48H48
C21.492,0,0,21.492,0,48z M86.336,54c0,10.492-8.508,19-19,19c-10.492,0-19-8.508-19-19s8.508-19,19-19
C77.828,35,86.336,43.508,86.336,54z M156.836,54c0,10.492-8.508,19-19,19c-10.492,0-19-8.508-19-19s8.508-19,19-19
C148.328,35,156.836,43.508,156.836,54z M227.336,54c0,10.492-8.508,19-19,19c-10.492,0-19-8.508-19-19s8.508-19,19-19
C218.828,35,227.336,43.508,227.336,54z M40,104h432v360c0,4.406-3.586,8-8,8H48c-4.414,0-8-3.594-8-8V104z"
/>
<rect x="264" y="192" class="st0" width="152" height="32" />
<rect x="88" y="352" class="st0" width="328" height="32" />
<rect x="88" y="192" class="st0" width="120" height="120" />
<polygon class="st0" points="282.958,304 264,304 264,272 416,272 416,304 298.958,304 " />
</g>
</svg>
</template>
<style scoped>
.st0 {
fill: var(--color-svg-fill);
}
</style>

View File

@@ -0,0 +1,15 @@
export default function IconItSupport() {
return (
<svg
fill="var(--color-svg-fill)"
viewBox="0 0 1920 1920"
xmlns="http://www.w3.org/2000/svg"
style={{ width: 32, height: 32 }}
>
<path
d="M960 0c530.193 0 960 429.807 960 960s-429.807 960-960 960S0 1490.193 0 960 429.807 0 960 0Zm0 101.053c-474.384 0-858.947 384.563-858.947 858.947S485.616 1818.947 960 1818.947 1818.947 1434.384 1818.947 960 1434.384 101.053 960 101.053Zm-42.074 626.795c-85.075 39.632-157.432 107.975-229.844 207.898-10.327 14.249-10.744 22.907-.135 30.565 7.458 5.384 11.792 3.662 22.656-7.928 1.453-1.562 1.453-1.562 2.94-3.174 9.391-10.17 16.956-18.8 33.115-37.565 53.392-62.005 79.472-87.526 120.003-110.867 35.075-20.198 65.9 9.485 60.03 47.471-1.647 10.664-4.483 18.534-11.791 35.432-2.907 6.722-4.133 9.646-5.496 13.23-13.173 34.63-24.269 63.518-47.519 123.85l-1.112 2.886c-7.03 18.242-7.03 18.242-14.053 36.48-30.45 79.138-48.927 127.666-67.991 178.988l-1.118 3.008a10180.575 10180.575 0 0 0-10.189 27.469c-21.844 59.238-34.337 97.729-43.838 138.668-1.484 6.37-1.484 6.37-2.988 12.845-5.353 23.158-8.218 38.081-9.82 53.42-2.77 26.522-.543 48.24 7.792 66.493 9.432 20.655 29.697 35.43 52.819 38.786 38.518 5.592 75.683 5.194 107.515-2.048 17.914-4.073 35.638-9.405 53.03-15.942 50.352-18.932 98.861-48.472 145.846-87.52 41.11-34.26 80.008-76 120.788-127.872 3.555-4.492 3.555-4.492 7.098-8.976 12.318-15.707 18.352-25.908 20.605-36.683 2.45-11.698-7.439-23.554-15.343-19.587-3.907 1.96-7.993 6.018-14.22 13.872-4.454 5.715-6.875 8.77-9.298 11.514-9.671 10.95-19.883 22.157-30.947 33.998-18.241 19.513-36.775 38.608-63.656 65.789-13.69 13.844-30.908 25.947-49.42 35.046-29.63 14.559-56.358-3.792-53.148-36.635 2.118-21.681 7.37-44.096 15.224-65.767 17.156-47.367 31.183-85.659 62.216-170.048 13.459-36.6 19.27-52.41 26.528-72.201 21.518-58.652 38.696-105.868 55.04-151.425 20.19-56.275 31.596-98.224 36.877-141.543 3.987-32.673-5.103-63.922-25.834-85.405-22.986-23.816-55.68-34.787-96.399-34.305-45.053.535-97.607 15.256-145.963 37.783Zm308.381-388.422c-80.963-31.5-178.114 22.616-194.382 108.33-11.795 62.124 11.412 115.76 58.78 138.225 93.898 44.531 206.587-26.823 206.592-130.826.005-57.855-24.705-97.718-70.99-115.729Z"
fillRule="evenodd"
/>
</svg>
)
}

View File

@@ -1,14 +0,0 @@
<template>
<svg
fill="var(--color-svg-fill)"
width="800px"
height="800px"
viewBox="0 0 1920 1920"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M960 0c530.193 0 960 429.807 960 960s-429.807 960-960 960S0 1490.193 0 960 429.807 0 960 0Zm0 101.053c-474.384 0-858.947 384.563-858.947 858.947S485.616 1818.947 960 1818.947 1818.947 1434.384 1818.947 960 1434.384 101.053 960 101.053Zm-42.074 626.795c-85.075 39.632-157.432 107.975-229.844 207.898-10.327 14.249-10.744 22.907-.135 30.565 7.458 5.384 11.792 3.662 22.656-7.928 1.453-1.562 1.453-1.562 2.94-3.174 9.391-10.17 16.956-18.8 33.115-37.565 53.392-62.005 79.472-87.526 120.003-110.867 35.075-20.198 65.9 9.485 60.03 47.471-1.647 10.664-4.483 18.534-11.791 35.432-2.907 6.722-4.133 9.646-5.496 13.23-13.173 34.63-24.269 63.518-47.519 123.85l-1.112 2.886c-7.03 18.242-7.03 18.242-14.053 36.48-30.45 79.138-48.927 127.666-67.991 178.988l-1.118 3.008a10180.575 10180.575 0 0 0-10.189 27.469c-21.844 59.238-34.337 97.729-43.838 138.668-1.484 6.37-1.484 6.37-2.988 12.845-5.353 23.158-8.218 38.081-9.82 53.42-2.77 26.522-.543 48.24 7.792 66.493 9.432 20.655 29.697 35.43 52.819 38.786 38.518 5.592 75.683 5.194 107.515-2.048 17.914-4.073 35.638-9.405 53.03-15.942 50.352-18.932 98.861-48.472 145.846-87.52 41.11-34.26 80.008-76 120.788-127.872 3.555-4.492 3.555-4.492 7.098-8.976 12.318-15.707 18.352-25.908 20.605-36.683 2.45-11.698-7.439-23.554-15.343-19.587-3.907 1.96-7.993 6.018-14.22 13.872-4.454 5.715-6.875 8.77-9.298 11.514-9.671 10.95-19.883 22.157-30.947 33.998-18.241 19.513-36.775 38.608-63.656 65.789-13.69 13.844-30.908 25.947-49.42 35.046-29.63 14.559-56.358-3.792-53.148-36.635 2.118-21.681 7.37-44.096 15.224-65.767 17.156-47.367 31.183-85.659 62.216-170.048 13.459-36.6 19.27-52.41 26.528-72.201 21.518-58.652 38.696-105.868 55.04-151.425 20.19-56.275 31.596-98.224 36.877-141.543 3.987-32.673-5.103-63.922-25.834-85.405-22.986-23.816-55.68-34.787-96.399-34.305-45.053.535-97.607 15.256-145.963 37.783Zm308.381-388.422c-80.963-31.5-178.114 22.616-194.382 108.33-11.795 62.124 11.412 115.76 58.78 138.225 93.898 44.531 206.587-26.823 206.592-130.826.005-57.855-24.705-97.718-70.99-115.729Z"
fill-rule="evenodd"
/>
</svg>
</template>

View File

@@ -0,0 +1,12 @@
export default function IconLinux() {
return (
<svg
fill="var(--color-svg-fill)"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
style={{ width: 32, height: 32 }}
>
<path d="M30.34,24.73a.77.77,0,0,1-.19-.79A2.75,2.75,0,0,0,27,20.38a16,16,0,0,0-3.48-8.62c-1.12-1.61-1.8-2.63-1.53-3.44A6.55,6.55,0,0,0,21,2.53,6,6,0,0,0,16,0a6,6,0,0,0-5,2.53,6.55,6.55,0,0,0-.94,5.79c.27.81-.4,1.83-1.53,3.44A16,16,0,0,0,5,20.38a2.75,2.75,0,0,0-3.19,3.56.77.77,0,0,1-.19.79l-.35.35a2.75,2.75,0,0,0-.76,2.45,2.79,2.79,0,0,0,1.57,2l4.63,2.1a4.79,4.79,0,0,0,2,.43A5,5,0,0,0,9.66,32a4.82,4.82,0,0,0,1.71-.72A14.11,14.11,0,0,0,16,32a14.06,14.06,0,0,0,4.63-.72,4.82,4.82,0,0,0,1.71.72,5,5,0,0,0,.94.09,4.79,4.79,0,0,0,2-.43l4.63-2.1a2.82,2.82,0,0,0,1.58-2,2.78,2.78,0,0,0-.77-2.45ZM12.61,3.7A4.06,4.06,0,0,1,16,2a4,4,0,0,1,3.39,1.7,4.53,4.53,0,0,1,.66,4,3.4,3.4,0,0,0-.15.92,1.23,1.23,0,0,0-.19-.31A5.32,5.32,0,0,0,16,7a5.35,5.35,0,0,0-3.71,1.29,1.23,1.23,0,0,0-.19.31A3.4,3.4,0,0,0,12,7.68,4.56,4.56,0,0,1,12.61,3.7ZM17,9.11,16,9.8l-1-.68A5.24,5.24,0,0,1,17,9.11ZM9.27,30a2.73,2.73,0,0,1-1.69-.19L3,27.74a.77.77,0,0,1-.22-1.25l.35-.35a2.77,2.77,0,0,0,.67-2.83.75.75,0,0,1,.18-.79.78.78,0,0,1,.54-.23.81.81,0,0,1,.25,0,2.78,2.78,0,0,0,1.28.1h.06l.31-.07.07,0a2.63,2.63,0,0,0,1.11-.66l.35-.35a.77.77,0,0,1,.69-.21.78.78,0,0,1,.56.44l2.1,4.62a2.84,2.84,0,0,1,.2,1.7A2.77,2.77,0,0,1,9.27,30Zm3.62-.38a4.81,4.81,0,0,0,.52-1.4,4.69,4.69,0,0,0-.34-2.91L11,20.71a2.74,2.74,0,0,0-3.84-1.27,15.07,15.07,0,0,1,3-6.53,9.8,9.8,0,0,0,1.9-3.65.92.92,0,0,0,.39.57l3,2A1,1,0,0,0,16,12a1,1,0,0,0,.56-.17l3-2a.94.94,0,0,0,.38-.57,9.8,9.8,0,0,0,1.9,3.65,15.07,15.07,0,0,1,3,6.53,2.76,2.76,0,0,0-1.81-.31,2.81,2.81,0,0,0-2,1.58l-2.1,4.63a4.74,4.74,0,0,0,.18,4.31,14,14,0,0,1-6.22,0Zm16.16-1.91-4.63,2.1a2.72,2.72,0,0,1-1.69.19,2.77,2.77,0,0,1-2.18-2.17,2.84,2.84,0,0,1,.2-1.7l2.1-4.62a.78.78,0,0,1,.56-.44h.15a.79.79,0,0,1,.54.22l.35.35a2.69,2.69,0,0,0,1.11.66l.07,0,.31.07h0a2.58,2.58,0,0,0,1.29-.09.78.78,0,0,1,1,1,2.75,2.75,0,0,0,.66,2.83l.35.35a.75.75,0,0,1,.22.68A.78.78,0,0,1,29.05,27.74Z" />
</svg>
)
}

View File

@@ -1,13 +0,0 @@
<template>
<svg
fill="var(--color-svg-fill)"
width="800px"
height="800px"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M30.34,24.73a.77.77,0,0,1-.19-.79A2.75,2.75,0,0,0,27,20.38a16,16,0,0,0-3.48-8.62c-1.12-1.61-1.8-2.63-1.53-3.44A6.55,6.55,0,0,0,21,2.53,6,6,0,0,0,16,0a6,6,0,0,0-5,2.53,6.55,6.55,0,0,0-.94,5.79c.27.81-.4,1.83-1.53,3.44A16,16,0,0,0,5,20.38a2.75,2.75,0,0,0-3.19,3.56.77.77,0,0,1-.19.79l-.35.35a2.75,2.75,0,0,0-.76,2.45,2.79,2.79,0,0,0,1.57,2l4.63,2.1a4.79,4.79,0,0,0,2,.43A5,5,0,0,0,9.66,32a4.82,4.82,0,0,0,1.71-.72A14.11,14.11,0,0,0,16,32a14.06,14.06,0,0,0,4.63-.72,4.82,4.82,0,0,0,1.71.72,5,5,0,0,0,.94.09,4.79,4.79,0,0,0,2-.43l4.63-2.1a2.82,2.82,0,0,0,1.58-2,2.78,2.78,0,0,0-.77-2.45ZM12.61,3.7A4.06,4.06,0,0,1,16,2a4,4,0,0,1,3.39,1.7,4.53,4.53,0,0,1,.66,4,3.4,3.4,0,0,0-.15.92,1.23,1.23,0,0,0-.19-.31A5.32,5.32,0,0,0,16,7a5.35,5.35,0,0,0-3.71,1.29,1.23,1.23,0,0,0-.19.31A3.4,3.4,0,0,0,12,7.68,4.56,4.56,0,0,1,12.61,3.7ZM17,9.11,16,9.8l-1-.68A5.24,5.24,0,0,1,17,9.11ZM9.27,30a2.73,2.73,0,0,1-1.69-.19L3,27.74a.77.77,0,0,1-.22-1.25l.35-.35a2.77,2.77,0,0,0,.67-2.83.75.75,0,0,1,.18-.79.78.78,0,0,1,.54-.23.81.81,0,0,1,.25,0,2.78,2.78,0,0,0,1.28.1h.06l.31-.07.07,0a2.63,2.63,0,0,0,1.11-.66l.35-.35a.77.77,0,0,1,.69-.21.78.78,0,0,1,.56.44l2.1,4.62a2.84,2.84,0,0,1,.2,1.7A2.77,2.77,0,0,1,9.27,30Zm3.62-.38a4.81,4.81,0,0,0,.52-1.4,4.69,4.69,0,0,0-.34-2.91L11,20.71a2.74,2.74,0,0,0-3.84-1.27,15.07,15.07,0,0,1,3-6.53,9.8,9.8,0,0,0,1.9-3.65.92.92,0,0,0,.39.57l3,2A1,1,0,0,0,16,12a1,1,0,0,0,.56-.17l3-2a.94.94,0,0,0,.38-.57,9.8,9.8,0,0,0,1.9,3.65,15.07,15.07,0,0,1,3,6.53,2.76,2.76,0,0,0-1.81-.31,2.81,2.81,0,0,0-2,1.58l-2.1,4.63a4.74,4.74,0,0,0,.18,4.31,14,14,0,0,1-6.22,0Zm16.16-1.91-4.63,2.1a2.72,2.72,0,0,1-1.69.19,2.77,2.77,0,0,1-2.18-2.17,2.84,2.84,0,0,1,.2-1.7l2.1-4.62a.78.78,0,0,1,.56-.44h.15a.79.79,0,0,1,.54.22l.35.35a2.69,2.69,0,0,0,1.11.66l.07,0,.31.07h0a2.58,2.58,0,0,0,1.29-.09.78.78,0,0,1,1,1,2.75,2.75,0,0,0,.66,2.83l.35.35a.75.75,0,0,1,.22.68A.78.78,0,0,1,29.05,27.74Z"
/>
</svg>
</template>

View File

@@ -1,26 +1,32 @@
<template> interface Props {
<svg className?: string
version="1.0" }
xmlns="http://www.w3.org/2000/svg"
:width="width" export default function IconLogo({ className }: Props) {
:height="height" return (
viewBox="0 0 222.000000 222.000000" <svg
preserveAspectRatio="xMidYMid meet" version="1.0"
class="logo-svg" xmlns="http://www.w3.org/2000/svg"
> width={125}
<g height={125}
transform="translate(0.000000,222.000000) scale(0.100000,-0.100000)" viewBox="0 0 222.000000 222.000000"
fill="var(--color-svg-fill)" preserveAspectRatio="xMidYMid meet"
stroke="none" className={className}
style={{ transition: 'fill 0.3s ease' }}
> >
<path <g
d="M0 1110 l0 -1110 1110 0 1110 0 0 1110 0 1110 -1110 0 -1110 0 0 transform="translate(0.000000,222.000000) scale(0.100000,-0.100000)"
fill="var(--color-svg-fill)"
stroke="none"
>
<path
d="M0 1110 l0 -1110 1110 0 1110 0 0 1110 0 1110 -1110 0 -1110 0 0
-1110z m2180 0 l0 -1070 -612 0 -613 0 222 222 c265 266 282 294 251 402 -10 -1110z m2180 0 l0 -1070 -612 0 -613 0 222 222 c265 266 282 294 251 402 -10
35 -66 96 -362 394 -193 195 -368 362 -388 373 -45 23 -115 25 -166 3 -24 -9 35 -66 96 -362 394 -193 195 -368 362 -388 373 -45 23 -115 25 -166 3 -24 -9
-115 -93 -254 -232 l-218 -216 0 597 0 597 1070 0 1070 0 0 -1070z" -115 -93 -254 -232 l-218 -216 0 597 0 597 1070 0 1070 0 0 -1070z"
/> />
<path <path
d="M124 2086 c-16 -13 -17 -18 -6 -35 9 -17 10 -25 0 -40 -9 -15 -9 -23 d="M124 2086 c-16 -13 -17 -18 -6 -35 9 -17 10 -25 0 -40 -9 -15 -9 -23
1 -35 11 -13 11 -19 0 -32 -10 -12 -10 -20 -1 -35 10 -15 9 -23 0 -39 -11 -17 1 -35 11 -13 11 -19 0 -32 -10 -12 -10 -20 -1 -35 10 -15 9 -23 0 -39 -11 -17
-10 -22 2 -30 12 -7 12 -12 0 -35 -12 -23 -12 -28 0 -35 12 -7 12 -12 0 -35 -10 -22 2 -30 12 -7 12 -12 0 -35 -12 -23 -12 -28 0 -35 12 -7 12 -12 0 -35
-12 -23 -12 -28 0 -35 12 -7 12 -12 0 -35 -12 -23 -12 -28 0 -35 8 -6 103 -10 -12 -23 -12 -28 0 -35 12 -7 12 -12 0 -35 -12 -23 -12 -28 0 -35 8 -6 103 -10
@@ -28,23 +34,23 @@
-1 13 6 21 9 12 9 16 0 19 -15 5 -17 39 -3 48 6 4 5 13 -3 25 -9 15 -9 25 -1 -1 13 6 21 9 12 9 16 0 19 -15 5 -17 39 -3 48 6 4 5 13 -3 25 -9 15 -9 25 -1
38 8 12 8 22 0 35 -8 12 -8 22 0 35 8 12 8 22 0 35 -8 12 -8 22 0 34 8 13 8 38 8 12 8 22 0 35 -8 12 -8 22 0 35 8 12 8 22 0 35 -8 12 -8 22 0 34 8 13 8
23 0 35 -8 13 -8 23 1 37 10 15 9 22 -6 35 -22 20 -100 21 -127 2z" 23 0 35 -8 13 -8 23 1 37 10 15 9 22 -6 35 -22 20 -100 21 -127 2z"
/> />
<path <path
d="M1199 1843 c-64 -32 -369 -325 -369 -355 0 -51 34 -29 193 130 200 d="M1199 1843 c-64 -32 -369 -325 -369 -355 0 -51 34 -29 193 130 200
199 250 226 343 187 38 -16 355 -322 399 -385 15 -21 29 -56 32 -78 10 -76 -9 199 250 226 343 187 38 -16 355 -322 399 -385 15 -21 29 -56 32 -78 10 -76 -9
-105 -190 -289 -167 -169 -190 -203 -139 -203 30 0 338 320 364 377 29 64 28 -105 -190 -289 -167 -169 -190 -203 -139 -203 30 0 338 320 364 377 29 64 28
135 -1 193 -30 59 -371 400 -428 429 -61 30 -135 28 -204 -6z" 135 -1 193 -30 59 -371 400 -428 429 -61 30 -135 28 -204 -6z"
/> />
<path <path
d="M1198 1571 c-56 -57 -98 -107 -98 -118 0 -25 40 -63 66 -63 19 0 224 d="M1198 1571 c-56 -57 -98 -107 -98 -118 0 -25 40 -63 66 -63 19 0 224
197 224 214 0 12 -22 51 -34 58 -34 23 -62 7 -158 -91z" 197 224 214 0 12 -22 51 -34 58 -34 23 -62 7 -158 -91z"
/> />
<path <path
d="M1479 1302 c-74 -73 -99 -104 -99 -123 0 -27 37 -69 60 -69 7 0 59 d="M1479 1302 c-74 -73 -99 -104 -99 -123 0 -27 37 -69 60 -69 7 0 59
46 116 102 110 108 122 130 84 168 -36 36 -57 25 -161 -78z" 46 116 102 110 108 122 130 84 168 -36 36 -57 25 -161 -78z"
/> />
<path <path
d="M1675 546 c-33 -24 -3 -46 60 -46 67 0 89 -8 75 -25 -8 -10 -8 -19 1 d="M1675 546 c-33 -24 -3 -46 60 -46 67 0 89 -8 75 -25 -8 -10 -8 -19 1
-33 10 -16 10 -23 0 -35 -10 -12 -10 -19 0 -35 10 -16 10 -23 0 -35 -10 -12 -33 10 -16 10 -23 0 -35 -10 -12 -10 -19 0 -35 10 -16 10 -23 0 -35 -10 -12
-10 -19 0 -35 10 -16 10 -23 0 -35 -10 -12 -10 -19 0 -35 10 -16 10 -23 0 -36 -10 -19 0 -35 10 -16 10 -23 0 -35 -10 -12 -10 -19 0 -35 10 -16 10 -23 0 -36
-11 -13 -11 -19 0 -32 10 -13 10 -20 0 -36 -20 -32 2 -48 69 -48 67 0 89 16 -11 -13 -11 -19 0 -32 10 -13 10 -20 0 -36 -20 -32 2 -48 69 -48 67 0 89 16
@@ -52,25 +58,8 @@
35 -10 16 -10 23 0 35 10 12 10 19 0 35 -10 16 -10 23 0 35 10 12 10 19 0 35 35 -10 16 -10 23 0 35 10 12 10 19 0 35 -10 16 -10 23 0 35 10 12 10 19 0 35
-10 16 -10 23 0 36 10 12 11 19 2 30 -16 19 5 26 74 26 63 0 93 22 60 46 -27 -10 16 -10 23 0 36 10 12 11 19 2 30 -16 19 5 26 74 26 63 0 93 22 60 46 -27
20 -383 20 -410 0z" 20 -383 20 -410 0z"
/> />
</g> </g>
</svg> </svg>
</template> )
<script setup lang="ts">
interface Props {
width?: string | number
height?: string | number
} }
withDefaults(defineProps<Props>(), {
width: 125,
height: 125,
})
</script>
<style scoped>
.logo-svg {
transition: fill 0.3s ease;
}
</style>

View File

@@ -0,0 +1,20 @@
export default function IconPhone() {
return (
<svg
className="icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ height: 30, width: 30, flexShrink: 0 }}
>
<path
d="M3 5.5C3 14.0604 9.93959 21 18.5 21C18.8862 21 19.2691 20.9859 19.6483 20.9581C20.0834 20.9262 20.3009 20.9103 20.499 20.7963C20.663 20.7019 20.8185 20.5345 20.9007 20.364C21 20.1582 21 19.9181 21 19.438V16.6207C21 16.2169 21 16.015 20.9335 15.842C20.8749 15.6891 20.7795 15.553 20.6559 15.4456C20.516 15.324 20.3262 15.255 19.9468 15.117L16.74 13.9509C16.2985 13.7904 16.0777 13.7101 15.8683 13.7237C15.6836 13.7357 15.5059 13.7988 15.3549 13.9058C15.1837 14.0271 15.0629 14.2285 14.8212 14.6314L14 16C11.3501 14.7999 9.2019 12.6489 8 10L9.36863 9.17882C9.77145 8.93713 9.97286 8.81628 10.0942 8.64506C10.2012 8.49408 10.2643 8.31637 10.2763 8.1317C10.2899 7.92227 10.2096 7.70153 10.0491 7.26005L8.88299 4.05321C8.745 3.67376 8.67601 3.48403 8.55442 3.3441C8.44701 3.22049 8.31089 3.12515 8.15802 3.06645C7.98496 3 7.78308 3 7.37932 3H4.56201C4.08188 3 3.84181 3 3.63598 3.09925C3.4655 3.18146 3.29814 3.33701 3.2037 3.50103C3.08968 3.69907 3.07375 3.91662 3.04189 4.35173C3.01413 4.73086 3 5.11378 3 5.5Z"
stroke="var(--color-svg-stroke)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ transition: 'stroke 0.3s ease' }}
/>
</svg>
)
}

View File

@@ -1,24 +0,0 @@
<template>
<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 5.5C3 14.0604 9.93959 21 18.5 21C18.8862 21 19.2691 20.9859 19.6483 20.9581C20.0834 20.9262 20.3009 20.9103 20.499 20.7963C20.663 20.7019 20.8185 20.5345 20.9007 20.364C21 20.1582 21 19.9181 21 19.438V16.6207C21 16.2169 21 16.015 20.9335 15.842C20.8749 15.6891 20.7795 15.553 20.6559 15.4456C20.516 15.324 20.3262 15.255 19.9468 15.117L16.74 13.9509C16.2985 13.7904 16.0777 13.7101 15.8683 13.7237C15.6836 13.7357 15.5059 13.7988 15.3549 13.9058C15.1837 14.0271 15.0629 14.2285 14.8212 14.6314L14 16C11.3501 14.7999 9.2019 12.6489 8 10L9.36863 9.17882C9.77145 8.93713 9.97286 8.81628 10.0942 8.64506C10.2012 8.49408 10.2643 8.31637 10.2763 8.1317C10.2899 7.92227 10.2096 7.70153 10.0491 7.26005L8.88299 4.05321C8.745 3.67376 8.67601 3.48403 8.55442 3.3441C8.44701 3.22049 8.31089 3.12515 8.15802 3.06645C7.98496 3 7.78308 3 7.37932 3H4.56201C4.08188 3 3.84181 3 3.63598 3.09925C3.4655 3.18146 3.29814 3.33701 3.2037 3.50103C3.08968 3.69907 3.07375 3.91662 3.04189 4.35173C3.01413 4.73086 3 5.11378 3 5.5Z"
stroke="#000000"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<style scoped>
.icon {
height: 30px;
width: 30px;
flex-shrink: 0;
}
.icon path {
stroke: var(--color-svg-stroke);
transition: stroke 0.3s ease;
}
</style>

View File

@@ -0,0 +1,19 @@
export default function IconRepair() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
style={{ width: 32, height: 32 }}
>
<path
fill="none"
stroke="var(--color-svg-stroke)"
strokeWidth="2"
strokeMiterlimit="10"
d="M22.2,12.8l-3-3l5-5C23.3,4.3,22.2,4,21,4
c-3.9,0-7,3.1-7,7c0,0.4,0,0.8,0.1,1.2c-2.9,2.9-8.3,8.3-8.9,8.9c-1.6,1.6-1.6,4.1,0,5.7c1.6,1.6,4.1,1.6,5.7,0c0.6-0.6,6-6,8.9-8.9
C20.2,18,20.6,18,21,18c3.9,0,7-3.1,7-7c0-1.2-0.3-2.3-0.8-3.2L22.2,12.8z"
/>
</svg>
)
}

View File

@@ -1,21 +0,0 @@
<template>
<svg
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 32 32"
enable-background="new 0 0 32 32"
xml:space="preserve"
>
<path
fill="none"
stroke="var(--color-svg-stroke)"
stroke-width="2"
stroke-miterlimit="10"
d="M22.2,12.8l-3-3l5-5C23.3,4.3,22.2,4,21,4
c-3.9,0-7,3.1-7,7c0,0.4,0,0.8,0.1,1.2c-2.9,2.9-8.3,8.3-8.9,8.9c-1.6,1.6-1.6,4.1,0,5.7c1.6,1.6,4.1,1.6,5.7,0c0.6-0.6,6-6,8.9-8.9
C20.2,18,20.6,18,21,18c3.9,0,7-3.1,7-7c0-1.2-0.3-2.3-0.8-3.2L22.2,12.8z"
/>
</svg>
</template>

View File

@@ -1,18 +1,7 @@
import { ref, computed } from 'vue' import { createContext, useContext, useState, type ReactNode } from 'react'
type Lang = 'fi' | 'en' type Lang = 'fi' | 'en'
const language = ref<Lang>((localStorage.getItem('language') as Lang) ?? 'fi')
const setLanguage = (lang: Lang) => {
language.value = lang
localStorage.setItem('language', lang)
}
const toggleLanguage = () => {
setLanguage(language.value === 'fi' ? 'en' : 'fi')
}
const translations = { const translations = {
fi: { fi: {
siteTitle: 'Livonsaaren Tietokonepaja', siteTitle: 'Livonsaaren Tietokonepaja',
@@ -22,7 +11,6 @@ const translations = {
navAbout: 'Tietoa', navAbout: 'Tietoa',
navContact: 'Yhteystiedot', navContact: 'Yhteystiedot',
// HomeSection
service1Heading: 'Kotisivut', service1Heading: 'Kotisivut',
service1Text: service1Text:
'Tarvitsetko kotisivut yrityksellesi tai yhdistyksellesi? Suunnittelemme ja toteutamme responsiiviset ja käyttäjäystävälliset kotisivut, jotka vastaavat tarpeitasi ja edistävät näkyvyyttäsi verkossa.', 'Tarvitsetko kotisivut yrityksellesi tai yhdistyksellesi? Suunnittelemme ja toteutamme responsiiviset ja käyttäjäystävälliset kotisivut, jotka vastaavat tarpeitasi ja edistävät näkyvyyttäsi verkossa.',
@@ -36,7 +24,6 @@ const translations = {
service4Text: service4Text:
'Vapaat ohjelmistot ja avoin lähdekoodi on lähellä sydäntämme. Tarjoamme Linux-käyttöjärjestelmä tukea ja asennuksia huokeasti. Mikäli vaihdat pois Windowsista, saat Linux asennuksen ilmaiseksi!', 'Vapaat ohjelmistot ja avoin lähdekoodi on lähellä sydäntämme. Tarjoamme Linux-käyttöjärjestelmä tukea ja asennuksia huokeasti. Mikäli vaihdat pois Windowsista, saat Linux asennuksen ilmaiseksi!',
// InfoView
infoHeading: 'Tietoa', infoHeading: 'Tietoa',
infoText: infoText:
'Livonsaaren Tietokonepaja vuonna 2024 perustettu kahden miehen projekti, jonka tehtävänä on tarjota matalankynnyksen IT-tukea ja -palveluita Livonsaaren ja lähialueiden asukkaille sekä yrityksille. Yrityksemme erikoistuu tietokoneiden huoltoon, ohjelmistojen asennukseen, kotisivuratkaisuihin ja Linux-käyttöjärjestelmän tukeen.', 'Livonsaaren Tietokonepaja vuonna 2024 perustettu kahden miehen projekti, jonka tehtävänä on tarjota matalankynnyksen IT-tukea ja -palveluita Livonsaaren ja lähialueiden asukkaille sekä yrityksille. Yrityksemme erikoistuu tietokoneiden huoltoon, ohjelmistojen asennukseen, kotisivuratkaisuihin ja Linux-käyttöjärjestelmän tukeen.',
@@ -54,13 +41,11 @@ const translations = {
visionText: visionText:
'Nykypäivänä erilaiset vempeleet ja palvelut vievät valtavasti rahaa ja aikaamme huonolla hyötysuhteella. Tämä johdosta tietokoneista onkin tullut monille kirosana. Me Tietokonepajalla haluamme olla rakentamassa uudenlaista tulevaisuutta, jossa teknologia palvelee käyttäjiään eikä päinvastoin. Vapaat ohjelmistot ja laitteiden kiertotalous tarjoaakin halvan, hallittavan ja mielekkään vaihtoehdon nykymenolle. Tule siis rohkeasti mukaan tekemään tietotekniikasta taas hauskaa ja hyödyllistä!', 'Nykypäivänä erilaiset vempeleet ja palvelut vievät valtavasti rahaa ja aikaamme huonolla hyötysuhteella. Tämä johdosta tietokoneista onkin tullut monille kirosana. Me Tietokonepajalla haluamme olla rakentamassa uudenlaista tulevaisuutta, jossa teknologia palvelee käyttäjiään eikä päinvastoin. Vapaat ohjelmistot ja laitteiden kiertotalous tarjoaakin halvan, hallittavan ja mielekkään vaihtoehdon nykymenolle. Tule siis rohkeasti mukaan tekemään tietotekniikasta taas hauskaa ja hyödyllistä!',
// ContactView
whoWeAreHeading: 'Keitä me olemme', whoWeAreHeading: 'Keitä me olemme',
veikkoAlt: 'Veikon kuva', veikkoAlt: 'Veikon kuva',
janiAlt: 'Janin kuva', janiAlt: 'Janin kuva',
contactHeading: 'Yhteydenotot', contactHeading: 'Yhteydenotot',
// PortfolioView
navPortfolio: 'Portfolio', navPortfolio: 'Portfolio',
portfolioHeading: 'Portfolio', portfolioHeading: 'Portfolio',
portfolioCustomerHeading: 'Asiakasprojektit', portfolioCustomerHeading: 'Asiakasprojektit',
@@ -104,7 +89,6 @@ const translations = {
navAbout: 'About', navAbout: 'About',
navContact: 'Contact', navContact: 'Contact',
// HomeSection
service1Heading: 'Websites', service1Heading: 'Websites',
service1Text: service1Text:
'Need a website for your business or association? We design and build responsive, user-friendly websites that meet your needs and boost your online visibility.', 'Need a website for your business or association? We design and build responsive, user-friendly websites that meet your needs and boost your online visibility.',
@@ -118,7 +102,6 @@ const translations = {
service4Text: service4Text:
'Free software and open source is close to our hearts. We offer Linux operating system support and installations at affordable prices. If you switch away from Windows, you get a Linux installation for free!', 'Free software and open source is close to our hearts. We offer Linux operating system support and installations at affordable prices. If you switch away from Windows, you get a Linux installation for free!',
// InfoView
infoHeading: 'About', infoHeading: 'About',
infoText: infoText:
'Livonsaaren Tietokonepaja is a two-person project founded in 2024, with the goal of providing accessible IT support and services to residents and businesses in Livonsaari and the surrounding area. Our company specializes in computer repairs, software installation, website solutions, and Linux operating system support.', 'Livonsaaren Tietokonepaja is a two-person project founded in 2024, with the goal of providing accessible IT support and services to residents and businesses in Livonsaari and the surrounding area. Our company specializes in computer repairs, software installation, website solutions, and Linux operating system support.',
@@ -135,13 +118,11 @@ const translations = {
visionText: visionText:
'Nowadays, various gadgets and services consume vast amounts of our money and time with poor value. As a result, computers have become a dirty word for many people. At Livonsaaren Tietokonepaja, we want to help build a new kind of future where technology serves its users, not the other way around. Free software and the circular economy of devices offer a cheap, controllable, and meaningful alternative to the current trend. So come join us in making technology fun and useful again!', 'Nowadays, various gadgets and services consume vast amounts of our money and time with poor value. As a result, computers have become a dirty word for many people. At Livonsaaren Tietokonepaja, we want to help build a new kind of future where technology serves its users, not the other way around. Free software and the circular economy of devices offer a cheap, controllable, and meaningful alternative to the current trend. So come join us in making technology fun and useful again!',
// ContactView
whoWeAreHeading: 'Who We Are', whoWeAreHeading: 'Who We Are',
veikkoAlt: 'Photo of Veikko', veikkoAlt: 'Photo of Veikko',
janiAlt: 'Photo of Jani', janiAlt: 'Photo of Jani',
contactHeading: 'Contact Us', contactHeading: 'Contact Us',
// PortfolioView
navPortfolio: 'Portfolio', navPortfolio: 'Portfolio',
portfolioHeading: 'Portfolio', portfolioHeading: 'Portfolio',
portfolioCustomerHeading: 'Customer Projects', portfolioCustomerHeading: 'Customer Projects',
@@ -179,7 +160,40 @@ const translations = {
}, },
} }
export function useLanguage() { export type Translations = typeof translations.fi
const t = computed(() => translations[language.value])
return { language, t, setLanguage, toggleLanguage } interface LanguageContextType {
language: Lang
t: Translations
setLanguage: (lang: Lang) => void
toggleLanguage: () => void
}
const LanguageContext = createContext<LanguageContextType | null>(null)
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLang] = useState<Lang>(
() => (localStorage.getItem('language') as Lang) ?? 'fi',
)
const setLanguage = (lang: Lang) => {
setLang(lang)
localStorage.setItem('language', lang)
}
const toggleLanguage = () => setLanguage(language === 'fi' ? 'en' : 'fi')
return (
<LanguageContext.Provider
value={{ language, t: translations[language], setLanguage, toggleLanguage }}
>
{children}
</LanguageContext.Provider>
)
}
export function useLanguage() {
const ctx = useContext(LanguageContext)
if (!ctx) throw new Error('useLanguage must be used within LanguageProvider')
return ctx
} }

View File

@@ -0,0 +1,53 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
interface ThemeContextType {
isDark: boolean
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState(() => {
const saved = localStorage.getItem('theme')
if (saved) return saved === 'dark'
return window.matchMedia('(prefers-color-scheme: dark)').matches
})
useEffect(() => {
if (isDark) {
document.documentElement.classList.add('dark')
document.documentElement.classList.remove('light')
} else {
document.documentElement.classList.add('light')
document.documentElement.classList.remove('dark')
}
}, [isDark])
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = (e: MediaQueryListEvent) => {
if (!localStorage.getItem('theme')) {
setIsDark(e.matches)
}
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const toggleTheme = () => {
setIsDark((prev) => {
const next = !prev
localStorage.setItem('theme', next ? 'dark' : 'light')
return next
})
}
return <ThemeContext.Provider value={{ isDark, toggleTheme }}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}

View File

@@ -1,11 +0,0 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')

16
src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import { LanguageProvider } from './contexts/LanguageContext'
import { ThemeProvider } from './contexts/ThemeContext'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<LanguageProvider>
<App />
</LanguageProvider>
</ThemeProvider>
</StrictMode>,
)

View File

@@ -1,33 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/InfoView.vue'),
},
{
path: '/contact',
name: 'contact',
component: () => import('../views/ContactView.vue'),
},
{
path: '/portfolio',
name: 'portfolio',
component: () => import('../views/PortfolioView.vue'),
},
],
})
export default router

View File

@@ -0,0 +1,96 @@
.contactItem {
margin-top: 2rem;
display: flex;
position: relative;
}
.contactItem:first-of-type {
margin-top: 0;
}
.details {
flex: 1;
margin-left: 1rem;
}
.contactPersonContainer {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
margin-top: 1rem;
}
.contactPerson {
display: flex;
align-items: center;
gap: 1rem;
flex-direction: column;
}
.contactInfo {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.details h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
.details h4 {
font-size: 1rem;
font-weight: 500;
margin-top: 1rem;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
.details p {
color: var(--color-text);
line-height: 1.6;
}
@media (min-width: 1024px) {
.contact {
min-height: 100vh;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
.contactItem {
margin-top: 0;
padding: 0.4rem 0 1rem 1rem;
}
.contactItem::before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.contactItem::after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.contactItem:first-of-type::before {
display: none;
}
.contactItem:last-of-type::after {
display: none;
}
}

60
src/views/ContactView.tsx Normal file
View File

@@ -0,0 +1,60 @@
import IconEmail from '../components/icons/IconEmail'
import IconPhone from '../components/icons/IconPhone'
import { useLanguage } from '../contexts/LanguageContext'
import styles from './ContactView.module.css'
import veikkoImg from '../assets/veikko.png'
import janiImg from '../assets/jani.png'
export default function ContactView() {
const { t } = useLanguage()
return (
<div className={styles.contact}>
<div className={styles.contactItem}>
<div className={styles.details}>
<h3>{t.whoWeAreHeading}</h3>
<div className={styles.contactPersonContainer}>
<div className={styles.contactPerson}>
<strong>Veikko</strong>
<img
src={veikkoImg}
alt={t.veikkoAlt}
style={{
width: 100,
height: 'auto',
display: 'block',
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}
/>
</div>
<div className={styles.contactPerson}>
<strong>Jani</strong>
<img
src={janiImg}
alt={t.janiAlt}
style={{
width: 100,
height: 'auto',
display: 'block',
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}
/>
</div>
</div>
<h4>{t.contactHeading}</h4>
<div className={styles.contactInfo}>
<IconEmail />
<a href="mailto:info@tietokonepaja.fi">info@tietokonepaja.fi</a>
</div>
<div className={styles.contactInfo}>
<IconPhone />
+358 442373706 (Veikko)
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,167 +0,0 @@
<script setup lang="ts">
import IconEmail from '@/components/icons/IconEmail.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
</script>
<template>
<div class="contact">
<div class="contact-item">
<div class="details">
<h3>{{ t.whoWeAreHeading }}</h3>
<div class="contact-person-container">
<div class="contact-person">
<strong>Veikko</strong>
<img
src="@/assets/veikko.png"
:alt="t.veikkoAlt"
style="
width: 100px;
height: auto;
display: block;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
"
/>
</div>
<div class="contact-person">
<strong>Jani</strong>
<img
src="@/assets/jani.png"
:alt="t.janiAlt"
style="
width: 100px;
height: auto;
display: block;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
"
/>
</div>
</div>
<h4>{{ t.contactHeading }}</h4>
<div class="contact-info">
<IconEmail />
<a href="mailto:info@tietokonepaja.fi">info@tietokonepaja.fi</a>
</div>
<div class="contact-info">
<IconPhone />
+358 442373706 (Veikko)
</div>
</div>
</div>
</div>
</template>
<style scoped>
.contact-item {
margin-top: 2rem;
display: flex;
position: relative;
}
.contact-item:first-of-type {
margin-top: 0;
}
.details {
flex: 1;
margin-left: 1rem;
}
.contact-person-container {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
margin-top: 1rem;
}
.contact-person {
display: flex;
align-items: center;
gap: 1rem;
flex-direction: column;
}
.contact-info {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
h4 {
font-size: 1rem;
font-weight: 500;
margin-top: 1rem;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
p {
color: var(--color-text);
line-height: 1.6;
}
ul {
color: var(--color-text);
line-height: 1.6;
margin: 0.5rem 0;
padding-left: 1.2rem;
}
li {
margin-bottom: 0.8rem;
}
@media (min-width: 1024px) {
.contact {
min-height: 100vh;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
.contact-item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
.contact-item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.contact-item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.contact-item:first-of-type:before {
display: none;
}
.contact-item:last-of-type:after {
display: none;
}
}
</style>

9
src/views/HomeView.tsx Normal file
View File

@@ -0,0 +1,9 @@
import HomeSection from '../components/HomeSection'
export default function HomeView() {
return (
<main>
<HomeSection />
</main>
)
}

View File

@@ -1,9 +0,0 @@
<script setup lang="ts">
import Home from '../components/HomeSection.vue'
</script>
<template>
<main>
<Home />
</main>
</template>

View File

@@ -0,0 +1,64 @@
strong {
font-weight: 600;
}
.aboutItem {
margin-top: 2rem;
display: flex;
position: relative;
}
.aboutItem:first-of-type {
margin-top: 0;
}
.details {
flex: 1;
margin-left: 1rem;
}
.details h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
.details p {
color: var(--color-text);
line-height: 1.6;
}
.details ul {
color: var(--color-text);
line-height: 1.6;
margin: 0.5rem 0;
padding-left: 1.2rem;
}
.details li {
margin-bottom: 0.8rem;
}
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
.aboutItem {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
.aboutItem:first-of-type::before {
display: none;
}
.aboutItem:last-of-type::after {
display: none;
}
}

41
src/views/InfoView.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { useLanguage } from '../contexts/LanguageContext'
import styles from './InfoView.module.css'
export default function InfoView() {
const { t } = useLanguage()
return (
<div className={styles.about}>
<div className={styles.aboutItem}>
<div className={styles.details}>
<h3>{t.infoHeading}</h3>
<p>{t.infoText}</p>
</div>
</div>
<div className={styles.aboutItem}>
<div className={styles.details}>
<h3>{t.valuesHeading}</h3>
<ul>
<li>
<strong>{t.value1Label}</strong> {t.value1Text}
</li>
<li>
<strong>{t.value2Label}</strong> {t.value2Text}
</li>
<li>
<strong>{t.value3Label}</strong> {t.value3Text}
</li>
</ul>
</div>
</div>
<div className={styles.aboutItem}>
<div className={styles.details}>
<h3>{t.visionHeading}</h3>
<p>{t.visionText}</p>
</div>
</div>
</div>
)
}

View File

@@ -1,125 +0,0 @@
<script setup lang="ts">
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
</script>
<template>
<div class="about">
<div class="about-item">
<div class="details">
<h3>{{ t.infoHeading }}</h3>
<p>{{ t.infoText }}</p>
</div>
</div>
<div class="about-item">
<div class="details">
<h3>{{ t.valuesHeading }}</h3>
<ul>
<li>
<strong>{{ t.value1Label }}</strong> {{ t.value1Text }}
</li>
<li>
<strong>{{ t.value2Label }}</strong> {{ t.value2Text }}
</li>
<li>
<strong>{{ t.value3Label }}</strong> {{ t.value3Text }}
</li>
</ul>
</div>
</div>
<div class="about-item">
<div class="details">
<h3>{{ t.visionHeading }}</h3>
<p>{{ t.visionText }}</p>
</div>
</div>
</div>
</template>
<style scoped>
strong {
font-weight: 600;
}
.about-item {
margin-top: 2rem;
display: flex;
position: relative;
}
.about-item:first-of-type {
margin-top: 0;
}
.details {
flex: 1;
margin-left: 1rem;
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
p {
color: var(--color-text);
line-height: 1.6;
}
ul {
color: var(--color-text);
line-height: 1.6;
margin: 0.5rem 0;
padding-left: 1.2rem;
}
li {
margin-bottom: 0.8rem;
}
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
.about-item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
.about-item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.about-item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.about-item:first-of-type:before {
display: none;
}
.about-item:last-of-type:after {
display: none;
}
}
</style>

View File

@@ -0,0 +1,129 @@
.portfolio {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
section {
display: flex;
flex-direction: column;
gap: 1rem;
}
section h3 {
font-size: 1.2rem;
font-weight: 500;
color: var(--color-heading);
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.4rem;
margin-bottom: 0.25rem;
}
.projectList {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.projectCard {
background-color: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.projectHeader {
width: 100%;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
align-items: center;
gap: 0.35rem 0.75rem;
padding: 0.75rem 1.25rem;
background: none;
border: none;
cursor: pointer;
color: inherit;
text-align: left;
}
.projectHeader:hover {
background-color: var(--color-background-mute);
}
.projectHeader h4 {
font-size: 1rem;
font-weight: 600;
color: var(--color-heading);
margin: 0;
grid-column: 1;
grid-row: 1;
}
.chevron {
font-size: 1.1rem;
color: var(--color-text);
transition: transform 0.25s ease;
line-height: 1;
grid-column: 2;
grid-row: 1;
align-self: center;
}
.chevron.open {
transform: rotate(180deg);
}
.tagList {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
grid-column: 1 / -1;
grid-row: 2;
}
.tag {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
color: #ffffff;
letter-spacing: 0.01em;
}
.expandWrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.28s ease;
}
.expandWrapper.expanded {
grid-template-rows: 1fr;
}
.expandContent {
overflow: hidden;
padding: 0 1.25rem;
transition: padding 0.28s ease;
}
.expandWrapper.expanded .expandContent {
padding: 0 1.25rem 0.9rem;
}
.expandContent p {
color: var(--color-text);
line-height: 1.6;
margin: 0 0 0.6rem;
font-size: 0.95rem;
}
.projectLinks {
display: flex;
gap: 0.75rem;
}
.projectLinks a {
font-size: 0.85rem;
}

171
src/views/PortfolioView.tsx Normal file
View File

@@ -0,0 +1,171 @@
import { useState } from 'react'
import { useLanguage, type Translations } from '../contexts/LanguageContext'
import styles from './PortfolioView.module.css'
interface Tag {
label: string
color: string
}
interface Project {
nameKey: keyof Translations
descKey: keyof Translations
url: string
sourceUrl?: string
tags: Tag[]
}
const customerProjects: Project[] = [
{
nameKey: 'portfolioProject1Name',
descKey: 'portfolioProject1Desc',
url: 'https://prosinervo.com/',
tags: [
{ label: 'WordPress', color: '#21759b' },
{ label: 'PHP', color: '#7a86b8' },
{ label: 'MySQL', color: '#4479a1' },
],
},
{
nameKey: 'portfolioProject2Name',
descKey: 'portfolioProject2Desc',
url: 'https://www.runosaari.fi/',
sourceUrl: 'https://gitea.tietokonepaja.fi/tietokonepaja/runosaari',
tags: [
{ label: 'Next.js', color: '#3d3d3d' },
{ label: 'React', color: '#087ea4' },
{ label: 'TypeScript', color: '#3178c6' },
{ label: 'SCSS', color: '#c6538c' },
],
},
{
nameKey: 'portfolioProject3Name',
descKey: 'portfolioProject3Desc',
url: 'https://www.livonsaarenosuuskauppa.fi/',
sourceUrl: 'https://gitea.tietokonepaja.fi/tietokonepaja/osuuskauppa',
tags: [
{ label: 'Next.js', color: '#3d3d3d' },
{ label: 'TypeScript', color: '#3178c6' },
{ label: 'SCSS', color: '#c6538c' },
{ label: 'Klapi API', color: '#2e7d32' },
],
},
]
const maintenanceProjects: Project[] = [
{
nameKey: 'portfolioMaint1Name',
descKey: 'portfolioMaint1Desc',
url: 'https://klapi.tietokonepaja.fi/',
sourceUrl: 'https://gitea.tietokonepaja.fi/tietokonepaja/klapi',
tags: [
{ label: 'ASP.NET Core', color: '#512bd4' },
{ label: 'C#', color: '#7b4fa6' },
{ label: 'TypeScript', color: '#3178c6' },
{ label: 'React', color: '#087ea4' },
{ label: 'SQLite', color: '#0f7b6c' },
],
},
{
nameKey: 'portfolioMaint2Name',
descKey: 'portfolioMaint2Desc',
url: 'https://mail.tietokonepaja.fi/',
tags: [
{ label: 'Mailcow', color: '#e65100' },
{ label: 'Docker', color: '#2496ed' },
{ label: 'Postfix', color: '#b71c1c' },
{ label: 'Dovecot', color: '#1565c0' },
{ label: 'Nginx', color: '#009900' },
],
},
{
nameKey: 'portfolioMaint3Name',
descKey: 'portfolioMaint3Desc',
url: 'https://gitea.tietokonepaja.fi/',
tags: [
{ label: 'Gitea', color: '#609926' },
{ label: 'Go', color: '#00add8' },
{ label: 'Docker', color: '#2496ed' },
],
},
{
nameKey: 'portfolioMaint4Name',
descKey: 'portfolioMaint4Desc',
url: 'https://hattara.tietokonepaja.fi/',
tags: [
{ label: 'Nextcloud', color: '#0082c9' },
{ label: 'PHP', color: '#7a86b8' },
{ label: 'Docker', color: '#2496ed' },
],
},
]
function ProjectCard({ project }: { project: Project }) {
const { t } = useLanguage()
const [open, setOpen] = useState(false)
return (
<div className={styles.projectCard}>
<button
className={styles.projectHeader}
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
>
<h4>{t[project.nameKey]}</h4>
<div className={styles.tagList}>
{project.tags.map((tag) => (
<span
key={tag.label}
className={styles.tag}
style={{ backgroundColor: tag.color }}
>
{tag.label}
</span>
))}
</div>
<span className={`${styles.chevron}${open ? ` ${styles.open}` : ''}`}></span>
</button>
<div className={`${styles.expandWrapper}${open ? ` ${styles.expanded}` : ''}`}>
<div className={styles.expandContent}>
<p>{t[project.descKey]}</p>
<div className={styles.projectLinks}>
<a href={project.url} target="_blank" rel="noopener noreferrer">
{t.portfolioVisitSite}
</a>
{project.sourceUrl && (
<a href={project.sourceUrl} target="_blank" rel="noopener noreferrer">
{t.portfolioSourceCode}
</a>
)}
</div>
</div>
</div>
</div>
)
}
export default function PortfolioView() {
const { t } = useLanguage()
return (
<div className={styles.portfolio}>
<section>
<h3>{t.portfolioCustomerHeading}</h3>
<div className={styles.projectList}>
{customerProjects.map((project) => (
<ProjectCard key={project.url} project={project} />
))}
</div>
</section>
<section>
<h3>{t.portfolioMaintenanceHeading}</h3>
<div className={styles.projectList}>
{maintenanceProjects.map((project) => (
<ProjectCard key={project.url} project={project} />
))}
</div>
</section>
</div>
)
}

View File

@@ -1,351 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
interface Tag {
label: string
color: string
}
interface Project {
nameKey: keyof typeof t.value
descKey: keyof typeof t.value
url: string
sourceUrl?: string
tags: Tag[]
}
const customerProjects: Project[] = [
{
nameKey: 'portfolioProject1Name',
descKey: 'portfolioProject1Desc',
url: 'https://prosinervo.com/',
tags: [
{ label: 'WordPress', color: '#21759b' },
{ label: 'PHP', color: '#7a86b8' },
{ label: 'MySQL', color: '#4479a1' },
],
},
{
nameKey: 'portfolioProject2Name',
descKey: 'portfolioProject2Desc',
url: 'https://www.runosaari.fi/',
sourceUrl: 'https://gitea.tietokonepaja.fi/tietokonepaja/runosaari',
tags: [
{ label: 'Next.js', color: '#3d3d3d' },
{ label: 'React', color: '#087ea4' },
{ label: 'TypeScript', color: '#3178c6' },
{ label: 'SCSS', color: '#c6538c' },
],
},
{
nameKey: 'portfolioProject3Name',
descKey: 'portfolioProject3Desc',
url: 'https://www.livonsaarenosuuskauppa.fi/',
sourceUrl: 'https://gitea.tietokonepaja.fi/tietokonepaja/osuuskauppa',
tags: [
{ label: 'Next.js', color: '#3d3d3d' },
{ label: 'TypeScript', color: '#3178c6' },
{ label: 'SCSS', color: '#c6538c' },
{ label: 'Klapi API', color: '#2e7d32' },
],
},
]
const maintenanceProjects: Project[] = [
{
nameKey: 'portfolioMaint1Name',
descKey: 'portfolioMaint1Desc',
url: 'https://klapi.tietokonepaja.fi/',
sourceUrl: 'https://gitea.tietokonepaja.fi/tietokonepaja/klapi',
tags: [
{ label: 'ASP.NET Core', color: '#512bd4' },
{ label: 'C#', color: '#7b4fa6' },
{ label: 'TypeScript', color: '#3178c6' },
{ label: 'React', color: '#087ea4' },
{ label: 'SQLite', color: '#0f7b6c' },
],
},
{
nameKey: 'portfolioMaint2Name',
descKey: 'portfolioMaint2Desc',
url: 'https://mail.tietokonepaja.fi/',
tags: [
{ label: 'Mailcow', color: '#e65100' },
{ label: 'Docker', color: '#2496ed' },
{ label: 'Postfix', color: '#b71c1c' },
{ label: 'Dovecot', color: '#1565c0' },
{ label: 'Nginx', color: '#009900' },
],
},
{
nameKey: 'portfolioMaint3Name',
descKey: 'portfolioMaint3Desc',
url: 'https://gitea.tietokonepaja.fi/',
tags: [
{ label: 'Gitea', color: '#609926' },
{ label: 'Go', color: '#00add8' },
{ label: 'Docker', color: '#2496ed' },
],
},
{
nameKey: 'portfolioMaint4Name',
descKey: 'portfolioMaint4Desc',
url: 'https://hattara.tietokonepaja.fi/',
tags: [
{ label: 'Nextcloud', color: '#0082c9' },
{ label: 'PHP', color: '#7a86b8' },
{ label: 'Docker', color: '#2496ed' },
],
},
]
const openItems = ref<Set<string>>(new Set())
function toggle(url: string) {
if (openItems.value.has(url)) {
openItems.value.delete(url)
} else {
openItems.value.add(url)
}
}
function isOpen(url: string) {
return openItems.value.has(url)
}
</script>
<template>
<div class="portfolio">
<section>
<h3>{{ t.portfolioCustomerHeading }}</h3>
<div class="project-list">
<div v-for="project in customerProjects" :key="project.url" class="project-card">
<button
class="project-header"
@click="toggle(project.url)"
:aria-expanded="isOpen(project.url)"
>
<h4>{{ t[project.nameKey] }}</h4>
<div class="header-right">
<div class="tag-list">
<span
v-for="tag in project.tags"
:key="tag.label"
class="tag"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}
</span>
</div>
<span class="chevron" :class="{ open: isOpen(project.url) }"></span>
</div>
</button>
<div class="expand-wrapper" :class="{ expanded: isOpen(project.url) }">
<div class="expand-content">
<p>{{ t[project.descKey] }}</p>
<div class="project-links">
<a :href="project.url" target="_blank" rel="noopener noreferrer">
{{ t.portfolioVisitSite }}
</a>
<a
v-if="project.sourceUrl"
:href="project.sourceUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ t.portfolioSourceCode }}
</a>
</div>
</div>
</div>
</div>
</div>
</section>
<section>
<h3>{{ t.portfolioMaintenanceHeading }}</h3>
<div class="project-list">
<div v-for="project in maintenanceProjects" :key="project.url" class="project-card">
<button
class="project-header"
@click="toggle(project.url)"
:aria-expanded="isOpen(project.url)"
>
<h4>{{ t[project.nameKey] }}</h4>
<div class="header-right">
<div class="tag-list">
<span
v-for="tag in project.tags"
:key="tag.label"
class="tag"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}
</span>
</div>
<span class="chevron" :class="{ open: isOpen(project.url) }"></span>
</div>
</button>
<div class="expand-wrapper" :class="{ expanded: isOpen(project.url) }">
<div class="expand-content">
<p>{{ t[project.descKey] }}</p>
<div class="project-links">
<a :href="project.url" target="_blank" rel="noopener noreferrer">
{{ t.portfolioVisitSite }}
</a>
<a
v-if="project.sourceUrl"
:href="project.sourceUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ t.portfolioSourceCode }}
</a>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.portfolio {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
section {
display: flex;
flex-direction: column;
gap: 1rem;
}
h3 {
font-size: 1.2rem;
font-weight: 500;
color: var(--color-heading);
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.4rem;
margin-bottom: 0.25rem;
}
.project-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.project-card {
background-color: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.project-header {
width: 100%;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
align-items: center;
gap: 0.35rem 0.75rem;
padding: 0.75rem 1.25rem;
background: none;
border: none;
cursor: pointer;
color: inherit;
text-align: left;
}
.project-header:hover {
background-color: var(--color-background-mute);
}
.header-right {
display: contents;
}
h4 {
font-size: 1rem;
font-weight: 600;
color: var(--color-heading);
margin: 0;
grid-column: 1;
grid-row: 1;
}
.chevron {
font-size: 1.1rem;
color: var(--color-text);
transition: transform 0.25s ease;
line-height: 1;
grid-column: 2;
grid-row: 1;
align-self: center;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
grid-column: 1 / -1;
grid-row: 2;
}
.chevron.open {
transform: rotate(180deg);
}
/* Expand/collapse animation using max-height */
.expand-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.28s ease;
}
.expand-wrapper.expanded {
grid-template-rows: 1fr;
}
.expand-content {
overflow: hidden;
padding: 0 1.25rem;
transition: padding 0.28s ease;
}
.expand-wrapper.expanded .expand-content {
padding: 0 1.25rem 0.9rem;
}
p {
color: var(--color-text);
line-height: 1.6;
margin: 0 0 0.6rem;
font-size: 0.95rem;
}
.project-links {
display: flex;
gap: 0.75rem;
}
.project-links a {
font-size: 0.85rem;
}
.tag {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
color: #ffffff;
letter-spacing: 0.01em;
}
</style>

View File

@@ -1,12 +1,29 @@
{ {
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./.cache/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./.cache/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
} },
"include": ["env.d.ts", "src"]
} }

View File

@@ -1,13 +1,11 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import react from '@vitejs/plugin-react'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue(), vueJsx(), vueDevTools()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)),