2025-03-03
Some checks failed
ci/woodpecker/push/publish_instable Pipeline failed

This commit is contained in:
gcch 2025-03-03 17:28:18 +01:00
commit 11fa3d1558
38 changed files with 819 additions and 148 deletions

View file

@ -9,7 +9,10 @@
"languages": {
"CSS": {
"formatter": null,
"format_on_save": "off"
"format_on_save": "off",
"code_actions_on_format": {
"source.fixAll.stylelint": true
}
},
"Vue.js": {
"code_actions_on_format": {
@ -31,6 +34,9 @@
"shortenToSingleLine": true
}
}
},
"stylelint": {
"configFile": "cfg/stylelint.config.mjs"
}
}
}

View file

@ -6,6 +6,7 @@
"dependencies": {
"@effect/platform": "latest",
"@effect/sql-drizzle": "latest",
"@thi.ng/color-palettes": "latest",
"@thi.ng/pixel": "latest",
"@thi.ng/pixel-dither": "latest",
"a11y-dialog": "latest",
@ -38,6 +39,7 @@
"prettier-plugin-sh": "latest",
"stylelint": "latest",
"stylelint-config-clean-order": "latest",
"stylelint-config-recommended-vue": "latest",
"stylelint-config-standard": "latest",
"stylelint-declaration-block-no-ignored-properties": "latest",
"stylelint-plugin-logical-css": "latest",
@ -369,20 +371,54 @@
"@thi.ng/api": ["@thi.ng/api@8.11.21", "", {}, "sha512-J6BUdUtFtwZirL3M9tkCiqBXj228z7zkxWOaDWTymwBeqY9s02vJP3mQV8l5p+YPDIRmYx/q7XVuLW1UTJRN/A=="],
"@thi.ng/arrays": ["@thi.ng/arrays@2.10.18", "", { "dependencies": { "@thi.ng/api": "^8.11.21", "@thi.ng/checks": "^3.7.1", "@thi.ng/compare": "^2.4.13", "@thi.ng/equiv": "^2.1.77", "@thi.ng/errors": "^2.5.27", "@thi.ng/random": "^4.1.12" } }, "sha512-4cJCVm67MqtrnwOWNdgeyM/WYAfEYeFtCh1x/3VptV6Ct8DSYyVbSK6RLYgsM04KQqAxEQniCl5/S+ZS3GFd+Q=="],
"@thi.ng/base-n": ["@thi.ng/base-n@2.7.33", "", {}, "sha512-WAVoTt1ZIYpDUs/+FauMh0p25MsG2BK0twNmJKWqAcPdP0zxb8TSQJCNrej/vTeq5c5XVsNpfGXIEa2WiXy5XQ=="],
"@thi.ng/binary": ["@thi.ng/binary@3.4.44", "", { "dependencies": { "@thi.ng/api": "^8.11.21" } }, "sha512-kd6ZZ0xWR5JivkSIjqb6MvFpK2us7dU3ruwmiBAw4oZKsTnfibTbgvtJLL8Q5BJnxYmI3aILQ1sCamxRMwbzBw=="],
"@thi.ng/canvas": ["@thi.ng/canvas@1.0.8", "", {}, "sha512-r4bRWAsiaaNx+ihTtQDi1RQFMfOLwGgoSbZhdxLzEo6t1Ga5a/cqv/WKqUWLjD1LHjxhtflS8uPcihD2ETyAOg=="],
"@thi.ng/checks": ["@thi.ng/checks@3.7.1", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-RfVBQgJN0kr00SKptAAzdDOaaRtWlctqegYogynTLkUhf8Ck516Efk/macgWTzSLEPgcumN7E9F1F3lgfRCWew=="],
"@thi.ng/color": ["@thi.ng/color@5.7.27", "", { "dependencies": { "@thi.ng/api": "^8.11.21", "@thi.ng/arrays": "^2.10.18", "@thi.ng/binary": "^3.4.44", "@thi.ng/checks": "^3.7.1", "@thi.ng/compare": "^2.4.13", "@thi.ng/compose": "^3.0.24", "@thi.ng/defmulti": "^3.0.61", "@thi.ng/errors": "^2.5.27", "@thi.ng/math": "^5.11.21", "@thi.ng/random": "^4.1.12", "@thi.ng/strings": "^3.9.6", "@thi.ng/transducers": "^9.2.21", "@thi.ng/vectors": "^7.12.23" } }, "sha512-zXI2X9lzGrp2I3o+qAg0ZFj+vWN9JMAFLfXvzLC/aAstn5N78eryhfQU55JXnlpuN4RkPbL+wlO/CZ/Sc37F1g=="],
"@thi.ng/color-palettes": ["@thi.ng/color-palettes@1.4.36", "", { "dependencies": { "@thi.ng/api": "^8.11.21", "@thi.ng/base-n": "^2.7.33", "@thi.ng/checks": "^3.7.1", "@thi.ng/color": "^5.7.27", "@thi.ng/errors": "^2.5.27", "@thi.ng/hex": "^2.3.65" } }, "sha512-D1dSSp1/RtTnqrBPAvvGzyZZcGFu+fempdUcSlLAd4SvE9zOZfWRS5kXn8IjwfzGCRtXL5vGcJ98Fv8N+BfYew=="],
"@thi.ng/compare": ["@thi.ng/compare@2.4.13", "", { "dependencies": { "@thi.ng/api": "^8.11.21" } }, "sha512-OOhxV5N7zrqKNS07ME8WZNBL81sFnE0BIjyVU0Z6zlwu2oH9mF7pZ6voF/wZH5mFfse8kcSxOA8oiQCCzCnPIQ=="],
"@thi.ng/compose": ["@thi.ng/compose@3.0.24", "", { "dependencies": { "@thi.ng/api": "^8.11.21", "@thi.ng/errors": "^2.5.27" } }, "sha512-WN6EeKt6EqDMICqrfGWqNpneMWw9OGaMA3T4ZFGD/AFEFmREZ0xsOMDR91oYotlvVDJeVKPYVa5CCLGx6ppQLQ=="],
"@thi.ng/defmulti": ["@thi.ng/defmulti@3.0.61", "", { "dependencies": { "@thi.ng/api": "^8.11.21", "@thi.ng/errors": "^2.5.27", "@thi.ng/logger": "^3.1.2" } }, "sha512-BfS0RtXgvphbvpQ9n5mDUbjsXlOafxEC8t8qL41bnPK0UJhA7IrJ/yfraDB2WmeTPGid70L9NdX0bg4ih7GGyg=="],
"@thi.ng/equiv": ["@thi.ng/equiv@2.1.77", "", {}, "sha512-qcpq7yMKNanK6NvoJaJOQeY2lHXuVWWERAHcM+wi3EpQwlVDO0smG3DOdZuAEcynnMli8JX3cyzAGFyZE86tZw=="],
"@thi.ng/errors": ["@thi.ng/errors@2.5.27", "", {}, "sha512-t1sgGuZqHv81lzNPSRySGHnDBvtt6h3MIMn3LdZnqMR0swwOIApw6YlheMYB63u94NdtuneQYcjdvbhOsHbwPQ=="],
"@thi.ng/hex": ["@thi.ng/hex@2.3.65", "", {}, "sha512-rX3U8DCayQVLkm6J6uMf6w1TSRQ3pE4okwkahS/A6/sDmABUlMo7t+s1psQhV07Jigr8q103+JHQpDdrcURiWw=="],
"@thi.ng/logger": ["@thi.ng/logger@3.1.2", "", {}, "sha512-WU/WCAOkxaLvGI2purG0iabueIG3Pq4CeoUogSHp/ctIh9vSVXplJYXlMy5PizttrHIJShJeHHu15x7IRyrAEQ=="],
"@thi.ng/math": ["@thi.ng/math@5.11.21", "", { "dependencies": { "@thi.ng/api": "^8.11.21" } }, "sha512-JLjHdQbCpIP6F76Vq8yBtRtDOaUJ5HKsyEhU++8bjPX7VeDf8um4Ba/PUQZS+SpX0ghTZrqirqoI7H6wWz/TaQ=="],
"@thi.ng/memoize": ["@thi.ng/memoize@4.0.11", "", { "dependencies": { "@thi.ng/api": "^8.11.21" } }, "sha512-acxutHHnYAF8WfxCgDN+V/euhmC/FnjcPg1oLxgCQNET42X5jzhYyxhOEwOnkv+DRezkcgpkM9TFNF/raawlpQ=="],
"@thi.ng/pixel": ["@thi.ng/pixel@7.3.19", "", { "dependencies": { "@thi.ng/api": "^8.11.21", "@thi.ng/canvas": "^1.0.8", "@thi.ng/checks": "^3.7.1", "@thi.ng/errors": "^2.5.27", "@thi.ng/math": "^5.11.21", "@thi.ng/porter-duff": "^2.1.99" } }, "sha512-k1oUFPktxOcJEh0RuzgwoZGBkNqB1zMNvk2QILph54Cj1JJH3CcAuqJTu+N6QkXoxsgoM4DtMiDcmIdyrRokJQ=="],
"@thi.ng/pixel-dither": ["@thi.ng/pixel-dither@1.1.159", "", { "dependencies": { "@thi.ng/checks": "^3.7.1", "@thi.ng/math": "^5.11.21", "@thi.ng/pixel": "^7.3.19" } }, "sha512-1tQfZUPgTEe/sn0URTHbkDEtWpJyqC4WqR9BMdOcE+g+pHhp1AjGB+eru1Mhx0WKJ/jcYl669U73Qooh8L2V6A=="],
"@thi.ng/porter-duff": ["@thi.ng/porter-duff@2.1.99", "", { "dependencies": { "@thi.ng/api": "^8.11.21" } }, "sha512-HC4rqfHGfAMijUoNlZslRZypb7MJ8BO6XpRF2Ol/O6JqHzhHtWlwbB/WGfoSiAUfdEHprSZ3KsPCDEl7MnRdpg=="],
"@thi.ng/random": ["@thi.ng/random@4.1.12", "", { "dependencies": { "@thi.ng/api": "^8.11.21", "@thi.ng/errors": "^2.5.27" } }, "sha512-IpcAgCGDdaHAahY1LJbJ9oULsmhCrQpGeAjPfB0gpGM1D7JXeOaY83ctt3tdqovT2YIaCMIUd8ahJt7VBMD+Ig=="],
"@thi.ng/strings": ["@thi.ng/strings@3.9.6", "", { "dependencies": { "@thi.ng/api": "^8.11.21", "@thi.ng/errors": "^2.5.27", "@thi.ng/hex": "^2.3.65", "@thi.ng/memoize": "^4.0.11" } }, "sha512-kdh66IfvlHz+iywyojTZCmUXg5d4NISrA3MwNm6fLUsuBA+yNVTOdf484Azbe1SQEUU5e9RLc6bfVeqkdthNxw=="],
"@thi.ng/timestamp": ["@thi.ng/timestamp@1.1.6", "", {}, "sha512-LGVbm9AiGwBcNHh2jieEtccy2edYCdHG3aK0no6ChH1AyAzonDdr05D+nBv0pl1h9C2AFPe9UQj43RdFgQJuUQ=="],
"@thi.ng/transducers": ["@thi.ng/transducers@9.2.21", "", { "dependencies": { "@thi.ng/api": "^8.11.21", "@thi.ng/arrays": "^2.10.18", "@thi.ng/checks": "^3.7.1", "@thi.ng/compare": "^2.4.13", "@thi.ng/compose": "^3.0.24", "@thi.ng/errors": "^2.5.27", "@thi.ng/math": "^5.11.21", "@thi.ng/random": "^4.1.12", "@thi.ng/timestamp": "^1.1.6" } }, "sha512-5uOiddZICcOGW5L8Y301bwjshfLZOvBlJpLneQzzsYbpqfQdOR4NlkbrJVw+IKEuqZU5B64eRqzJzOwjDAsd0g=="],
"@thi.ng/vectors": ["@thi.ng/vectors@7.12.23", "", { "dependencies": { "@thi.ng/api": "^8.11.21", "@thi.ng/binary": "^3.4.44", "@thi.ng/checks": "^3.7.1", "@thi.ng/equiv": "^2.1.77", "@thi.ng/errors": "^2.5.27", "@thi.ng/math": "^5.11.21", "@thi.ng/memoize": "^4.0.11", "@thi.ng/random": "^4.1.12", "@thi.ng/strings": "^3.9.6", "@thi.ng/transducers": "^9.2.21" } }, "sha512-+PbtaA2cYApQrzGpOKC3Yg4jxWiJw6qEchTH+YRyjVA6YdAlSdptwrvEYdUCiKhPLqa0WUrKzCUw4mqIE75yDA=="],
"@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="],
"@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
@ -439,7 +475,7 @@
"@vue/eslint-config-typescript": ["@vue/eslint-config-typescript@14.4.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.23.0", "fast-glob": "^3.3.3", "typescript-eslint": "^8.23.0", "vue-eslint-parser": "^9.4.3" }, "peerDependencies": { "eslint": "^9.10.0", "eslint-plugin-vue": "^9.28.0", "typescript": ">=4.8.4" }, "optionalPeers": ["typescript"] }, "sha512-daU+eAekEeVz3CReE4PRW25fe+OJDKwE28jHN6LimDEnuFMbJ6H4WGogEpNof276wVP6UvzOeJQfLFjB5mW29A=="],
"@vue/language-core": ["@vue/language-core@2.2.4", "", { "dependencies": { "@volar/language-core": "~2.4.11", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^1.0.3", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-eGGdw7eWUwdIn9Fy/irJ7uavCGfgemuHQABgJ/hU1UgZFnbTg9VWeXvHQdhY+2SPQZWJqWXvRWIg67t4iWEa+Q=="],
"@vue/language-core": ["@vue/language-core@2.2.8", "", { "dependencies": { "@volar/language-core": "~2.4.11", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^1.0.3", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-rrzB0wPGBvcwaSNRriVWdNAbHQWSf0NlGqgKHK5mEkXpefjUlVRP62u03KvwZpvKVjRnBIQ/Lwre+Mx9N6juUQ=="],
"@vue/reactivity": ["@vue/reactivity@3.5.13", "", { "dependencies": { "@vue/shared": "3.5.13" } }, "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg=="],
@ -451,7 +487,7 @@
"@vue/shared": ["@vue/shared@3.5.13", "", {}, "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ=="],
"@vue/typescript-plugin": ["@vue/typescript-plugin@2.2.4", "", { "dependencies": { "@volar/typescript": "~2.4.11", "@vue/language-core": "2.2.4", "@vue/shared": "^3.5.0" } }, "sha512-bwklUVy7TZu0Nn9d+oII0KA10eYSgqarf47/E1QvkoHS8EP5SJzPKvOhMPGJMyxwHtHE4aOu8jdWZcJ25X5eiQ=="],
"@vue/typescript-plugin": ["@vue/typescript-plugin@2.2.8", "", { "dependencies": { "@volar/typescript": "~2.4.11", "@vue/language-core": "2.2.8", "@vue/shared": "^3.5.0" } }, "sha512-9fzhFYrzIsPm5Qylv6yBmV1tISqkUhE1PD5uBwkeLCxIwNUjIbnGBdN8HszDa1ZWFWuBsbQpx7FxmV7vQincDw=="],
"a11y-dialog": ["a11y-dialog@8.1.1", "", { "dependencies": { "focusable-selectors": "^0.8.0" } }, "sha512-7SBLXFwhQBnEHOaIiKUUQZ5VKJa/b1jBDvPJvlejlqX2w9cpi+iHBrdjcmd4Xd6vIdsuMHGo9Is2SWu0Hzu0zg=="],
@ -573,6 +609,14 @@
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"drizzle-kit": ["drizzle-kit@0.30.5", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-l6dMSE100u7sDaTbLczibrQZjA35jLsHNqIV+jmhNVO3O8jzM6kywMOmV9uOz9ZVSCMPQhAZEFjL/qDPVrqpUA=="],
"drizzle-orm": ["drizzle-orm@0.40.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7ptk/HQiMSrEZHnAsSlBESXWj52VwgMmyTEfoNmpNN2ZXpcz13LwHfXTIghsAEud7Z5UJhDOp8U07ujcqme7wg=="],
@ -703,6 +747,8 @@
"html-tags": ["html-tags@3.3.1", "", {}, "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ=="],
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@ -735,7 +781,7 @@
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@ -867,6 +913,8 @@
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
"postcss-html": ["postcss-html@1.8.0", "", { "dependencies": { "htmlparser2": "^8.0.0", "js-tokens": "^9.0.0", "postcss": "^8.5.0", "postcss-safe-parser": "^6.0.0" } }, "sha512-5mMeb1TgLWoRKxZ0Xh9RZDfwUUIqRrcxO2uXO+Ezl1N5lqpCiSU5Gk6+1kZediBfBHFtPCdopr2UZ2SgUsKcgQ=="],
"postcss-resolve-nested-selector": ["postcss-resolve-nested-selector@0.1.6", "", {}, "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw=="],
"postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="],
@ -879,7 +927,7 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.5.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg=="],
"prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
"prettier-plugin-pkg": ["prettier-plugin-pkg@0.18.1", "", { "peerDependencies": { "prettier": "^3.0.3" } }, "sha512-FuUxvsYZR/8rsLH8s/jbPQmgYvv0yxW8LoIHCy6+Q7p4FBjjdP3DNKx8fMTOsc0SlEB1skB4o1LcahRceIh87A=="],
@ -949,8 +997,12 @@
"stylelint-config-clean-order": ["stylelint-config-clean-order@7.0.0", "", { "dependencies": { "stylelint-order": "^6.0.4" }, "peerDependencies": { "stylelint": ">=14" } }, "sha512-R28w1xNliIbem3o+VIrNjAU8cMgxrGlDoXVqWW7lJ1OvSDsmNGj5aKSW6Xm7i5PK4E99T3Hs19BJFni5IbE56g=="],
"stylelint-config-html": ["stylelint-config-html@1.1.0", "", { "peerDependencies": { "postcss-html": "^1.0.0", "stylelint": ">=14.0.0" } }, "sha512-IZv4IVESjKLumUGi+HWeb7skgO6/g4VMuAYrJdlqQFndgbj6WJAXPhaysvBiXefX79upBdQVumgYcdd17gCpjQ=="],
"stylelint-config-recommended": ["stylelint-config-recommended@15.0.0", "", { "peerDependencies": { "stylelint": "^16.13.0" } }, "sha512-9LejMFsat7L+NXttdHdTq94byn25TD+82bzGRiV1Pgasl99pWnwipXS5DguTpp3nP1XjvLXVnEJIuYBfsRjRkA=="],
"stylelint-config-recommended-vue": ["stylelint-config-recommended-vue@1.6.0", "", { "dependencies": { "semver": "^7.3.5", "stylelint-config-html": ">=1.0.0", "stylelint-config-recommended": ">=6.0.0" }, "peerDependencies": { "postcss-html": "^1.0.0", "stylelint": ">=14.0.0" } }, "sha512-syk1adIHvbH2T1OiR/spUK4oQy35PZIDw8Zmc7E0+eVK9Z9SK3tdMpGRT/bgGnAPpMt/WaL9K1u0tlF6xM0sMQ=="],
"stylelint-config-standard": ["stylelint-config-standard@37.0.0", "", { "dependencies": { "stylelint-config-recommended": "^15.0.0" }, "peerDependencies": { "stylelint": "^16.13.0" } }, "sha512-+6eBlbSTrOn/il2RlV0zYGQwRTkr+WtzuVSs1reaWGObxnxLpbcspCUYajVQHonVfxVw2U+h42azGhrBvcg8OA=="],
"stylelint-declaration-block-no-ignored-properties": ["stylelint-declaration-block-no-ignored-properties@2.8.0", "", { "peerDependencies": { "stylelint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-Ws8Cav7Y+SPN0JsV407LrnNXWOrqGjxShf+37GBtnU/C58Syve9c0+I/xpLcFOosST3ternykn3Lp77f3ITnFw=="],
@ -1013,7 +1065,7 @@
"vue-router": ["vue-router@4.5.0", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w=="],
"vue-tsc": ["vue-tsc@2.2.4", "", { "dependencies": { "@volar/typescript": "~2.4.11", "@vue/language-core": "2.2.4" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-3EVHlxtpMXcb5bCaK7QDFTbEkMusDfVk0HVRrkv5hEb+Clpu9a96lKUXJAeD/akRlkoA4H8MCHgBDN19S6FnzA=="],
"vue-tsc": ["vue-tsc@2.2.8", "", { "dependencies": { "@volar/typescript": "~2.4.11", "@vue/language-core": "2.2.8" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-jBYKBNFADTN+L+MdesNX/TB3XuDSyaWynKMDgR+yCSln0GQ9Tfb7JS2lr46s2LiFUT1WsmfWsSvIElyxzOPqcQ=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
@ -1037,6 +1089,8 @@
"zod-validation-error": ["zod-validation-error@3.4.0", "", { "peerDependencies": { "zod": "^3.18.0" } }, "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@csstools/selector-specificity/postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
@ -1093,6 +1147,8 @@
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.0.3", "", {}, "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw=="],
"postcss-html/postcss-safe-parser": ["postcss-safe-parser@6.0.0", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ=="],
"stylelint/file-entry-cache": ["file-entry-cache@10.0.6", "", { "dependencies": { "flat-cache": "^6.1.6" } }, "sha512-0wvv16mVo9nN0Md3k7DMjgAPKG/TY4F/gYMBVb/wMThFRJvzrpaqBFqF6km9wf8QfYTN+mNg5aeaBLfy8k35uA=="],
"stylelint/ignore": ["ignore@7.0.3", "", {}, "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA=="],

View file

@ -44,12 +44,18 @@
"useTabs": false
},
"markup": {
"astro.scriptIndent": true,
"astro.styleIndent": true,
"astroAttrShorthand": true,
"closingBracketSameLine": false,
"closingTagLineBreakForEmpty": "never",
"component.selfClosing": false,
"component.whitespaceSensitivity": "strict",
"doctypeKeywordCase": "lower",
"formatComments": true,
"html.normal.selfClosing": false,
"html.scriptIndent": true,
"html.styleIndent": true,
"html.void.selfClosing": false,
"indentWidth": 2,
"lineBreak": "lf",
@ -60,8 +66,15 @@
"scriptFormatter": "dprint",
"scriptIndent": true,
"styleIndent": true,
"svg.selfClosing": true,
"svg.selfClosing": false,
"useTabs": false,
"vBindSameNameShortHand": true,
"vBindStyle": "short",
"vForDelimiterStyle": "of",
"vOnStyle": "short",
"vSlotStyle": "short",
"vue.scriptIndent": true,
"vue.styleIndent": true,
"whitespaceSensitivity": "strict"
},
"newLineKind": "lf",

View file

@ -1,17 +1,15 @@
// @ts-expect-error -- La dépendance ne dispose pas de types.
import { propertyGroups } from "stylelint-config-clean-order";
const propertiesOrder = Array.from(propertyGroups).map(properties => ({
emptyLineBefore: "never",
noEmptyLineBetween: true,
properties,
}));
/** @type {import("stylelint").Config} */
export default {
extends: ["stylelint-config-standard", "stylelint-config-clean-order"],
extends: ["stylelint-config-standard", "stylelint-config-clean-order", "stylelint-config-recommended-vue"],
plugins: ["stylelint-declaration-block-no-ignored-properties"],
rules: {
"custom-property-pattern": null,

View file

@ -13,6 +13,7 @@
"vtsls",
"quartary",
"fieldset",
"tabindex"
"tabindex",
"currentcolor"
]
}

View file

@ -5,7 +5,7 @@ services:
dockerfile: Dockerfile
container_name: journal-media-vue
ports:
- 127.0.0.1:8080:80
- 127.0.0.1:8080:8
restart: unless-stopped
develop:
watch:

View file

@ -33,7 +33,7 @@ dev:
# Compile le projet.
build:
-bun --bun vue-tsc --build .
-bun --bun vue-tsc --build --noEmit
bun --bun vite build
# Génère l'image Docker.
@ -84,7 +84,7 @@ lint-css:
--cache --cache-location "{{ cacheFolder }}/{{ stylelintCacheFile }}" \
--config "{{ stylelintConfigFile }}" \
--fix \
{{ stylesFolder }}
"**/*.{css,vue}"
# Analyse le code TypeScript et Vue.
lint-js fix="":
@ -93,6 +93,10 @@ lint-js fix="":
--config "{{ esLintConfigFile }}" \
{{ fix }}
# Vérifie les types du code TypeScript et Vue avec le compilateur TypeScript.
lint-types:
bun vue-tsc --noEmit
# Analyse le code CSS avec ESLint.
lint-css-eslint fix="":
bun --bun eslint --config "{{ esLintCssConfigFile }}" {{ fix }}

View file

@ -6,6 +6,7 @@
"dependencies": {
"@effect/platform": "^0.77.4",
"@effect/sql-drizzle": "^0.29.4",
"@thi.ng/color-palettes": "^1.4.36",
"@thi.ng/pixel": "^7.3.19",
"@thi.ng/pixel-dither": "^1.1.159",
"a11y-dialog": "^8.1.1",
@ -22,7 +23,7 @@
"@types/bun": "^1.2.4",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^14.4.0",
"@vue/typescript-plugin": "^2.2.4",
"@vue/typescript-plugin": "^2.2.8",
"browserslist": "^4.24.4",
"cspell": "^8.17.5",
"drizzle-kit": "^0.30.5",
@ -33,17 +34,18 @@
"jiti": "^2.4.2",
"knip": "^5.45.0",
"lightningcss": "^1.29.1",
"prettier": "^3.5.2",
"prettier": "^3.5.3",
"prettier-plugin-pkg": "^0.18.1",
"prettier-plugin-sh": "^0.15.0",
"stylelint": "^16.15.0",
"stylelint-config-clean-order": "^7.0.0",
"stylelint-config-recommended-vue": "^1.6.0",
"stylelint-config-standard": "^37.0.0",
"stylelint-declaration-block-no-ignored-properties": "^2.8.0",
"stylelint-plugin-logical-css": "^1.2.1",
"tsr": "^1.3.4",
"typescript": "^5.8.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.4"
"vue-tsc": "^2.2.8"
}
}

View file

@ -2,13 +2,13 @@
#app-loading {
position: fixed;
inset: 0;
text-align: center;
align-content: center;
z-index: 100;
background: salmon;
inset: 0;
align-content: center;
font-family: Banquise;
font-size: 2rem;
text-align: center;
background: salmon;
p {
width: 10ch;
@ -25,15 +25,19 @@
0% {
content: "";
}
25% {
content: ".";
}
50% {
content: "..";
}
75% {
content: "...";
}
100% {
content: "";
}

View file

@ -2,6 +2,7 @@
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 750 B

Before After
Before After

96
public/sortable-table.css Normal file
View file

@ -0,0 +1,96 @@
.sr-only {
position: absolute;
top: -30em;
}
table.sortable td, table.sortable th {
width: 8em;
padding: 0.125em 0.25em;
}
table.sortable th {
position: relative;
border-bottom: thin solid #888;
font-weight: bold;
}
table.sortable th.no-sort {
padding-top: 0.35em;
}
table.sortable th:nth-child(5) {
width: 10em;
}
table.sortable th button {
cursor: pointer;
inset: 0;
display: inline;
width: 100%;
margin: 1px;
padding: 4px;
border: none;
font-size: 100%;
font-weight: bold;
text-align: left;
background: transparent;
outline: none;
}
table.sortable th button span {
position: absolute;
right: 4px;
}
table.sortable th[aria-sort="descending"] span::after {
content: "▼";
top: 0;
font-size: 100%;
color: currentcolor;
}
table.sortable th[aria-sort="ascending"] span::after {
content: "▲";
top: 0;
font-size: 100%;
color: currentcolor;
}
table.show-unsorted-icon th:not([aria-sort]) button span::after {
content: "♢";
position: relative;
top: -3px;
left: -4px;
font-size: 100%;
color: currentcolor;
}
table.sortable td.num {
text-align: right;
}
table.sortable tbody tr:nth-child(odd) {
background-color: #ddd;
}
/* Focus and hover styling */
table.sortable th button:focus, table.sortable th button:hover {
padding: 2px;
border: 2px solid currentcolor;
background-color: #e5f4ff;
}
table.sortable th button:focus span, table.sortable th button:hover span {
right: 2px;
}
table.sortable th:not([aria-sort]) button:focus span::after, table.sortable
th:not([aria-sort])
button:hover
span::after {
content: "▼";
top: 0;
font-size: 100%;
color: currentcolor;
}

167
public/sortable-table.js Normal file
View file

@ -0,0 +1,167 @@
/*
* This content is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
*
* File: sortable-table.js
*
* Desc: Adds sorting to a HTML data table that implements ARIA Authoring Practices
*/
"use strict";
class SortableTable {
constructor(tableNode) {
this.tableNode = tableNode;
this.columnHeaders = tableNode.querySelectorAll("thead th");
this.sortColumns = [];
for (var i = 0; i < this.columnHeaders.length; i++) {
var ch = this.columnHeaders[i];
var buttonNode = ch.querySelector("button");
if (buttonNode) {
this.sortColumns.push(i);
buttonNode.setAttribute("data-column-index", i);
buttonNode.addEventListener("click", this.handleClick.bind(this));
}
}
this.optionCheckbox = document.querySelector(
"input[type=\"checkbox\"][value=\"show-unsorted-icon\"]",
);
if (this.optionCheckbox) {
this.optionCheckbox.addEventListener(
"change",
this.handleOptionChange.bind(this),
);
if (this.optionCheckbox.checked) {
this.tableNode.classList.add("show-unsorted-icon");
}
}
}
handleClick(event) {
var tgt = event.currentTarget;
this.setColumnHeaderSort(tgt.getAttribute("data-column-index"));
}
handleOptionChange(event) {
var tgt = event.currentTarget;
if (tgt.checked) {
this.tableNode.classList.add("show-unsorted-icon");
} else {
this.tableNode.classList.remove("show-unsorted-icon");
}
}
/* EVENT HANDLERS */
setColumnHeaderSort(columnIndex) {
if (typeof columnIndex === "string") {
columnIndex = parseInt(columnIndex);
}
for (var i = 0; i < this.columnHeaders.length; i++) {
var ch = this.columnHeaders[i];
var buttonNode = ch.querySelector("button");
if (i === columnIndex) {
var value = ch.getAttribute("aria-sort");
if (value === "descending") {
ch.setAttribute("aria-sort", "ascending");
this.sortColumn(
columnIndex,
"ascending",
ch.classList.contains("num"),
);
} else {
ch.setAttribute("aria-sort", "descending");
this.sortColumn(
columnIndex,
"descending",
ch.classList.contains("num"),
);
}
} else {
if (ch.hasAttribute("aria-sort") && buttonNode) {
ch.removeAttribute("aria-sort");
}
}
}
}
sortColumn(columnIndex, sortValue, isNumber) {
function compareValues(a, b) {
if (sortValue === "ascending") {
if (a.value === b.value) {
return 0;
} else {
if (isNumber) {
return a.value - b.value;
} else {
return a.value < b.value ? -1 : 1;
}
}
} else {
if (a.value === b.value) {
return 0;
} else {
if (isNumber) {
return b.value - a.value;
} else {
return a.value > b.value ? -1 : 1;
}
}
}
}
if (typeof isNumber !== "boolean") {
isNumber = false;
}
var tbodyNode = this.tableNode.querySelector("tbody");
var rowNodes = [];
var dataCells = [];
var rowNode = tbodyNode.firstElementChild;
var index = 0;
while (rowNode) {
rowNodes.push(rowNode);
var rowCells = rowNode.querySelectorAll("th, td");
var dataCell = rowCells[columnIndex];
var data = {};
data.index = index;
data.value = dataCell.textContent.toLowerCase().trim();
if (isNumber) {
data.value = parseFloat(data.value);
}
dataCells.push(data);
rowNode = rowNode.nextElementSibling;
index += 1;
}
dataCells.sort(compareValues);
// remove rows
while (tbodyNode.firstChild) {
tbodyNode.removeChild(tbodyNode.lastChild);
}
// add sorted rows
for (var i = 0; i < dataCells.length; i += 1) {
tbodyNode.appendChild(rowNodes[dataCells[i].index]);
}
}
}
// Initialize sortable table buttons
window.addEventListener("load", function() {
var sortableTables = document.querySelectorAll("table.sortable");
for (var i = 0; i < sortableTables.length; i++) {
new SortableTable(sortableTables[i]);
}
});

View file

@ -1,10 +0,0 @@
<script setup lang="ts">
import type { TmdbMovieSearchResponseResult } from "@/libs/apis/tmdb/schemas";
defineProps<{
entryId: number;
tmdbSearchData: TmdbMovieSearchResponseResult;
}>();
</script>
<template></template>

View file

@ -1,35 +1,58 @@
<script setup lang="ts">
import type { MergedTmdbLocalData } from "@/libs/search/schemas";
import type { Values } from "@/libs/utils/types";
import type { MergedTmdbLocalData } from "@/libs/search/schemas.ts";
import type { AriaSortValues } from "@/libs/search/types.ts";
import type { Values } from "@/libs/utils/types.ts";
import { tupleByTitle } from "@/libs/apis/tmdb/orders.ts";
import TableHeadingSortableColumn from "@/components/tables/TableHeadingSortableColumn.vue";
import {
getTmdbSortFunction,
TMDB_SORT_VALUES,
type TmdbSortData,
type TmdbSortValues,
toggleSortOrder,
} from "@/libs/apis/tmdb/orders.ts";
import { ARIA_SORT_VALUES } from "@/libs/search/constants";
import { Array as Arr, Match, pipe } from "effect";
import { computed, ref } from "vue";
const SORT_ORDERS = {
ORIGINAL: "original",
POPULARITY: "popularity",
RELEASE_DATE: "release_date",
TITLE: "title",
} as const;
const emit = defineEmits<(e: "entry-dialog-wanted", tmdbId: number) => void>();
const { searchData } = defineProps<{ searchData: Map<number, MergedTmdbLocalData> }>();
const sortOrder = ref<Values<typeof SORT_ORDERS>>(SORT_ORDERS.TITLE);
const sort = ref<TmdbSortData>({
sortOrder: ARIA_SORT_VALUES.NONE,
sortValue: TMDB_SORT_VALUES.ORIGINAL,
});
const sortedData = computed(() =>
pipe(
Array.from(searchData.entries()),
(result: [number, MergedTmdbLocalData][]) =>
Match.value(sortOrder.value).pipe(
Match.when(SORT_ORDERS.ORIGINAL, () => result),
Match.when(SORT_ORDERS.POPULARITY, () => Arr.sort(result, tupleByTitle)),
Match.when(SORT_ORDERS.RELEASE_DATE, () => Arr.sort(result, tupleByTitle)),
Match.when(SORT_ORDERS.TITLE, () => Arr.sort(result, tupleByTitle)),
Match.exhaustive,
),
(result: [number, MergedTmdbLocalData][]) => Arr.sort(result, getTmdbSortFunction(sort.value)),
)
);
console.debug(sortedData.value);
const updateSort = (newSortValue: Values<typeof TMDB_SORT_VALUES>): void => {
const oldSort = sort.value;
const isNewSortValue = oldSort.sortValue !== newSortValue;
const newSortOrder: AriaSortValues = Match.value(isNewSortValue).pipe(
Match.when(false, () => toggleSortOrder(oldSort.sortOrder)),
Match.orElse(() => ARIA_SORT_VALUES.ASCENDING),
);
sort.value = { sortOrder: newSortOrder, sortValue: newSortValue };
};
const getSortData = (sortValue: TmdbSortValues): TmdbSortData => {
return {
sortOrder: sortValue === sort.value.sortValue ? sort.value.sortOrder : ARIA_SORT_VALUES.NONE,
sortValue,
};
};
// Gestionnaire d'événements
const onRowClicked = (tmdbId: number) => {
emit("entry-dialog-wanted", tmdbId);
};
</script>
<template>
@ -37,18 +60,38 @@
<table v-show="sortedData?.length">
<thead>
<tr>
<th scope="col">Nom</th>
<th scope="col">Année</th>
<th scope="col">Popularité</th>
<TableHeadingSortableColumn
:sort-data="getSortData(TMDB_SORT_VALUES.ORIGINAL)" @click="updateSort(TMDB_SORT_VALUES.ORIGINAL)"
>
Index
</TableHeadingSortableColumn>
<TableHeadingSortableColumn
:sort-data="getSortData(TMDB_SORT_VALUES.TITLE)" @click="updateSort(TMDB_SORT_VALUES.TITLE)"
>
Nom
</TableHeadingSortableColumn>
<TableHeadingSortableColumn
:sort-data="getSortData(TMDB_SORT_VALUES.RELEASE_DATE)" @click="updateSort(TMDB_SORT_VALUES.RELEASE_DATE)"
>
Date
</TableHeadingSortableColumn>
<TableHeadingSortableColumn
:sort-data="getSortData(TMDB_SORT_VALUES.POPULARITY)" @click="updateSort(TMDB_SORT_VALUES.POPULARITY)"
>
Popularité
</TableHeadingSortableColumn>
</tr>
</thead>
<tbody>
<tr
v-for="result in sortedData" :key="result[0]" class="row-link"
v-for="result of sortedData" :key="result[0]" class="row-link"
:data-artwork-id="result[1].artWorkId" :data-entry-id="result[1].entryId" :data-tmdb-id="result[0]"
tabindex="0"
tabindex="0" @click="onRowClicked(result[0])" @keypress="onRowClicked(result[0])"
>
<th class="name" scope="row">
{{ result[1].original_result_index }}
</th>
<th class="name" scope="row">
{{ result[1].original_title }}
</th>
@ -80,13 +123,6 @@
}
}
thead th {
font-weight: 120;
font-size: var(--s0);
text-transform: uppercase;
letter-spacing: 1px;
}
tbody tr > * + * {
padding-inline-start: var(--s-2);
}
@ -100,8 +136,8 @@
cursor: pointer;
&:hover {
background: var(--root-text-color);
color: var(--root-background-color);
background: var(--root-text-color);
}
&:active {

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
import type { MergedTmdbLocalData } from "@/libs/search/schemas";
import ImposterBox from "@/components/dialogs/ImposterBox.vue";
import { Images } from "@/services/images.ts";
import { RuntimeClient } from "@/services/runtime-client";
import { Url } from "@effect/platform";
import { Effect } from "effect";
import { onMounted } from "vue";
import { ref } from "vue";
import { watchEffect } from "vue";
import { useTemplateRef } from "vue";
const emit = defineEmits(["dialog-hidden"]);
const { entryData } = defineProps<{ entryData: MergedTmdbLocalData }>();
const ditheredPoster = ref<HTMLCanvasElement>();
const imageContainer = useTemplateRef("imageContainer");
const closeDialog = () => {
emit("dialog-hidden");
};
watchEffect(async () => {
ditheredPoster.value = await RuntimeClient.runPromise(Effect.gen(function*() {
if (!entryData.artWorkCoverPath || !imageContainer.value) return undefined;
const imageService: Images = yield* Images;
const originalUrl = yield* Url.fromString(`https://image.tmdb.org/t/p/w500/${entryData.artWorkCoverPath}`);
const originalImage = yield* imageService.imageFromUrl(originalUrl);
const ditheredImage = yield* imageService.ditherImage(originalImage, imageContainer.value);
return ditheredImage;
}));
});
onMounted(() => {
console.debug("EditEntryDialog mounted");
});
</script>
<template>
<ImposterBox dialog-id="edit-entry" :is-toggled="true" @dialog-hidden="closeDialog">
<template #title>Éditer une entrée</template>
<template #content>
<section aria-labelledby="media-title" class="switcher">
<div ref="imageContainer" class="canvas-container"> </div>
<div class="stack">
<h3 id="media-title">{{ entryData.original_title }}</h3>
<p class="center">{{ entryData.release_date }} | {{ entryData.original_language }} </p>
<p class="overview">{{ entryData.overview }}</p>
</div>
</section>
</template>
</ImposterBox>
</template>
<style scoped lang="css">
.canvas-container {
aspect-ratio: 0.6;
width: 400px;
max-width: 400px;
height: 600px;
max-height: 600px;
background: var(--bg25-secondary);
> * {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.overview {
max-inline-size: 40rem;
}
</style>

View file

@ -3,6 +3,7 @@
import A11yDialog from "a11y-dialog";
import { computed, ref, useTemplateRef, watchEffect } from "vue";
import { onMounted, onUnmounted } from "vue";
const { dialogId, isToggled } = defineProps<{
/** ID de la modale. */
@ -26,6 +27,14 @@
dialog.value?.show();
}
});
onMounted(() => {
console.debug("ImposterBox mounted");
});
onUnmounted(() => {
console.debug("ImposterBox unmounted");
});
</script>
<template>

View file

@ -1,7 +1,8 @@
<script setup lang="ts">
import Search from "@/libs/search/search.ts";
import { formDataToRecord } from "@/libs/search/search.ts";
import { Effect, pipe } from "effect";
import { useTemplateRef } from "vue";
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import ImposterBox from "./ImposterBox.vue";
@ -29,11 +30,15 @@
await pipe(
Effect.fromNullable(form.value),
Effect.andThen((form: HTMLFormElement) => new FormData(form)),
Effect.andThen((formData: FormData) => Search.formDataToRecord(formData)),
Effect.andThen((formData: FormData) => formDataToRecord(formData)),
Effect.tap(query => router.push({ path: "/search", query })),
Effect.runPromise,
);
};
onMounted(() => {
console.debug("SearchMediaDialog mounted");
});
</script>
<template>
@ -66,8 +71,8 @@
<div class="field stack">
<label for="query">Titre</label>
<input
id="query" for="add-media-form" name="query"
required type="text"
id="query" autofocus for="add-media-form"
name="query" required type="text"
>
</div>
<div class="field stack">

View file

@ -0,0 +1,70 @@
<script setup lang="ts">
import type { TmdbSortData } from "@/libs/apis/tmdb/orders";
const emit = defineEmits(["click"]);
const { sortData } = defineProps<{ sortData: TmdbSortData }>();
const onButtonClicked = (event: Event): void => {
event.preventDefault();
emit("click");
};
</script>
<template>
<th :aria-sort="sortData.sortOrder" scope="col">
<button
class="button-invisible" :data-sort-value="sortData.sortValue" role="button"
@click="onButtonClicked"
>
<slot></slot>
<span aria-hidden="true" class="sort-indicator"></span>
</button>
</th>
</template>
<style lang="css" scoped>
th {
position: relative;
}
button {
display: inline-block;
align-content: center;
font-size: var(--s-1);
font-weight: var(--brkly-font-weight-semibold);
text-align: left;
text-transform: uppercase;
letter-spacing: var(--letter-spacing-small);
background: inherit;
.sort-indicator {
opacity: 0.5;
&::after {
content: "♢";
display: inline-block;
min-inline-size: var(--s-1);
border-inline-end-style: var(--s3);
color: currentcolor;
text-align: center;
table th[aria-sort="descending"] & {
content: "▼";
color: currentcolor;
}
table th[aria-sort="ascending"] & {
content: "▲";
color: currentcolor;
}
}
}
&:hover {
.sort-indicator {
opacity: 1;
}
}
}
</style>

View file

@ -1,7 +1,7 @@
CREATE TABLE `diary_entries` (
`art_work_id` integer NOT NULL,
`date_created` text(10) NOT NULL,
`date_modified` text(10) NOT NULL,
`date_created` integer NOT NULL,
`date_modified` integer NOT NULL,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`state_id` integer NOT NULL,
FOREIGN KEY (`art_work_id`) REFERENCES `art_works`(`id`) ON UPDATE no action ON DELETE no action,
@ -24,13 +24,16 @@ CREATE TABLE `diary_entries_states` (
CREATE UNIQUE INDEX `diary_entries_states_state_unique` ON `diary_entries_states` (`state`);--> statement-breakpoint
CREATE TABLE `viewings` (
`art_work_id` integer NOT NULL,
`date` text(10) NOT NULL,
`date` integer NOT NULL,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
FOREIGN KEY (`art_work_id`) REFERENCES `art_works`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `art_works` (
`cover_path` text,
`date_created` integer NOT NULL,
`date_metadata_updated` integer NOT NULL,
`date_updated` integer NOT NULL,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`medium_type_id` integer NOT NULL,
`name` text NOT NULL,

View file

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "da9e1cf6-aba7-4b5a-a839-3b0fe3dce876",
"id": "8b217318-7662-4f81-bc55-92d5c6ddf2b2",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"diary_entries": {
@ -16,14 +16,14 @@
},
"date_created": {
"name": "date_created",
"type": "text(10)",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date_modified": {
"name": "date_modified",
"type": "text(10)",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@ -178,7 +178,7 @@
},
"date": {
"name": "date",
"type": "text(10)",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@ -221,6 +221,27 @@
"notNull": false,
"autoincrement": false
},
"date_created": {
"name": "date_created",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date_metadata_updated": {
"name": "date_metadata_updated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date_updated": {
"name": "date_updated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "integer",

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1740666437095,
"tag": "0000_open_the_twelve",
"when": 1740814587298,
"tag": "0000_unusual_karen_page",
"breakpoints": true
}
]

View file

@ -12,8 +12,8 @@ import { ArtWorks, Genres } from "./works";
export const DiaryEntries = table("diary_entries", {
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id).notNull(),
dateCreated: t.text("date_created", { length: 10 }).notNull(),
dateModified: t.text("date_modified", { length: 10 }).notNull(),
dateCreated: t.integer("date_created", { mode: "timestamp" }).notNull(),
dateModified: t.integer("date_modified", { mode: "timestamp" }).notNull(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
stateId: t.integer("state_id").references((): AnySQLiteColumn => DiaryEntriesStates.id).notNull(),
});
@ -31,7 +31,7 @@ export const DiaryEntriesStates = table("diary_entries_states", {
export const Viewings = table("viewings", {
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id).notNull(),
date: t.text("date", { length: 10 }).notNull(),
date: t.integer("date", { mode: "timestamp" }).notNull(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
});
@ -39,8 +39,8 @@ export const Viewings = table("viewings", {
export const DiaryEntrySchema = Schema.Struct({
artWorkId: Schema.NonNegativeInt,
dateCreated: Schema.String,
dateModified: Schema.String,
dateCreated: Schema.Number,
dateModified: Schema.Number,
id: Schema.NonNegativeInt,
stateId: Schema.NonNegativeInt,
});
@ -61,7 +61,7 @@ export type DiaryEntryState = Schema.Schema.Type<typeof DiaryEntryStateSchema>;
export const ViewingSchema = Schema.Struct({
artWorkId: Schema.NonNegativeInt,
date: Schema.String,
date: Schema.Number,
id: Schema.NonNegativeInt,
});
export type Viewing = Schema.Schema.Type<typeof ViewingSchema>;

View file

@ -17,6 +17,9 @@ export const MediaTypes = table("media_types", {
export const ArtWorks = table("art_works", {
coverPath: t.text("cover_path").unique(),
dateCreated: t.integer("date_created", { mode: "timestamp" }).notNull(),
dateMetadataUpdated: t.integer("date_metadata_updated", { mode: "timestamp" }).notNull(),
dateUpdated: t.integer("date_updated", { mode: "timestamp" }).notNull(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
mediumTypeId: t.integer("medium_type_id").references((): AnySQLiteColumn => MediaTypes.id).notNull(),
name: t.text("name").notNull(),
@ -45,6 +48,12 @@ export type MediaType = Schema.Schema.Type<typeof MediaTypeSchema>;
export const ArtWorkSchema = Schema.Struct({
/** Le chemin de l'image de la pochette de l'oeuvre d'art. */
coverPath: Schema.Union(Schema.String, Schema.Null),
/** La date de création de l'entrée. */
dateCreated: Schema.Number,
/** La date de dernière mise à jour des métadonnées de l'entrée depuis l'API TMDB. */
dateMetadataUpdated: Schema.Number,
/** La date de dernière mise à jour de l'entrée. */
dateUpdated: Schema.Number,
/** L'ID numérique de l'ouvre d'art. */
id: Schema.NonNegativeInt,
/** L'ID numérique du type de l'oeuvre d'art. */

View file

@ -1,22 +1,63 @@
import type { MergedTmdbLocalData } from "@/libs/search/schemas";
import type { AriaSortValues } from "@/libs/search/types";
import type { Values } from "@/libs/utils/types";
import { Order } from "effect";
import { ARIA_SORT_VALUES } from "@/libs/search/constants";
import { Match, Order, pipe } from "effect";
import type { TmdbMovieSearchResponseResult } from "./schemas";
export const TMDB_SORT_VALUES = {
ORIGINAL: "original",
POPULARITY: "popularity",
RELEASE_DATE: "release_date",
TITLE: "title",
} as const;
export const byTitle = Order.mapInput(
Order.string,
(tmdbEntry: TmdbMovieSearchResponseResult) => tmdbEntry.original_title,
export type TmdbSortValues = Values<typeof TMDB_SORT_VALUES>;
export interface TmdbSortData {
sortOrder: AriaSortValues;
sortValue: TmdbSortValues;
}
export const toggleSortOrder = (order: AriaSortValues): AriaSortValues =>
Match.value(order).pipe(
Match.when(ARIA_SORT_VALUES.ASCENDING, () => ARIA_SORT_VALUES.DESCENDING),
Match.when(ARIA_SORT_VALUES.DESCENDING, () => ARIA_SORT_VALUES.ASCENDING),
Match.when(ARIA_SORT_VALUES.NONE, () => ARIA_SORT_VALUES.ASCENDING),
Match.exhaustive,
);
export const getTmdbSortFunction = (sortData: TmdbSortData) =>
pipe(
// Récupère la fonction de tri correspondant à la propriété demandé.
Match.value(sortData.sortValue).pipe(
Match.when(TMDB_SORT_VALUES.ORIGINAL, () => byOriginalIndexAscending),
Match.when(TMDB_SORT_VALUES.POPULARITY, () => byPopularityAscending),
Match.when(TMDB_SORT_VALUES.RELEASE_DATE, () => byReleaseDateAscending),
Match.when(TMDB_SORT_VALUES.TITLE, () => byTitleAscending),
Match.orElse(() => byTitleAscending),
),
// Applique le bon sens (ascendant/descendant).
(sortFunction: Order.Order<[number, MergedTmdbLocalData]>) =>
Match.value(sortData.sortOrder).pipe(
Match.when(ARIA_SORT_VALUES.DESCENDING, () => Order.reverse(sortFunction)),
Match.orElse(() => sortFunction),
),
);
export const byOriginalIndexAscending = Order.mapInput(
Order.number,
(data: [number, MergedTmdbLocalData]) => data[1].original_result_index,
);
export const byReleaseDate = Order.mapInput(
Order.string,
(tmdbEntry: TmdbMovieSearchResponseResult) => tmdbEntry.release_date,
export const byPopularityAscending = Order.mapInput(
Order.number,
(data: [number, MergedTmdbLocalData]) => data[1].popularity,
);
// Tuples
export const tupleByTitle = Order.mapInput(
export const byReleaseDateAscending = Order.mapInput(
Order.string,
(data: [number, MergedTmdbLocalData]) => data[1].release_date,
);
export const byTitleAscending = Order.mapInput(
Order.string,
(data: [number, MergedTmdbLocalData]) => data[1].title,
);

View file

@ -0,0 +1,5 @@
export const ARIA_SORT_VALUES = {
ASCENDING: "ascending",
DESCENDING: "descending",
NONE: "none",
} as const;

View file

@ -11,12 +11,13 @@ export class MergedTmdbLocalData extends Schema.Class<MergedTmdbLocalData>("Merg
artWorkCoverPath: Schema.Union(Schema.String, Schema.Null),
artWorkId: Schema.NonNegativeInt.pipe(Schema.optional),
artWorkMediumTypeId: Schema.NonNegativeInt.pipe(Schema.optional),
entryDateCreated: Schema.String.pipe(Schema.optional),
entryDateModified: Schema.String.pipe(Schema.optional),
entryDateCreated: Schema.Date.pipe(Schema.optional),
entryDateModified: Schema.Date.pipe(Schema.optional),
entryId: Schema.NonNegativeInt.pipe(Schema.optional),
entryStateId: Schema.NonNegativeInt.pipe(Schema.optional),
genre_ids: Schema.Array(Schema.NonNegativeInt),
original_language: Schema.String,
original_result_index: Schema.Int,
original_title: Schema.String,
overview: Schema.String,
popularity: Schema.Number,

View file

@ -1,7 +1,13 @@
import type { NonEmptyArray } from "effect/Array";
import type { Router } from "vue-router";
import { PrettyLogger } from "@/services/logger";
import { UrlParams } from "@effect/platform";
import { Effect, pipe } from "effect";
import { Effect, Match, pipe } from "effect";
import type { AriaSortValues } from "./types";
import { ARIA_SORT_VALUES } from "./constants";
/**
* Transforme les valeurs d'un `FormData` en `Record` trié.
@ -9,7 +15,7 @@ import { Effect, pipe } from "effect";
* @param formData Les valeurs d'un formulaire.
* @returns Un `Effect` des valeurs.
*/
const formDataToRecord = (formData: FormData): Effect.Effect<Record<string, NonEmptyArray<string> | string>> =>
export const formDataToRecord = (formData: FormData): Effect.Effect<Record<string, NonEmptyArray<string> | string>> =>
pipe(
Effect.succeed(Array.from(formData.entries())),
// @ts-expect-error -- Impossible de typer les valeurs de FormData comme string.
@ -23,4 +29,30 @@ const formDataToRecord = (formData: FormData): Effect.Effect<Record<string, NonE
Effect.andThen((urlParams: UrlParams.UrlParams) => UrlParams.toRecord(urlParams)),
);
export default { formDataToRecord };
export const updateSortOrder = (sortOrder: AriaSortValues) =>
Match.value(sortOrder).pipe(
Match.when(ARIA_SORT_VALUES.NONE, () => ARIA_SORT_VALUES.ASCENDING),
Match.when(ARIA_SORT_VALUES.ASCENDING, () => ARIA_SORT_VALUES.DESCENDING),
Match.when(ARIA_SORT_VALUES.DESCENDING, () => ARIA_SORT_VALUES.ASCENDING),
Match.exhaustive,
);
export const updateUrlQueryFromFormData =
(router: Router, form: HTMLFormElement | null) => async (event?: Event): Promise<void> => {
event?.preventDefault();
await pipe(
// Garantis que l'Élément soit bien présent.
Effect.fromNullable(form),
Effect.andThen((form: HTMLFormElement) => new FormData(form)),
Effect.andThen((searchFormData: FormData) => formDataToRecord(searchFormData)),
// Met à jour les paramètres de l'URL.
Effect.tap((routeQueryParams: Record<string, NonEmptyArray<string> | string>) =>
router.push({ force: true, query: routeQueryParams })
),
Effect.tapError(Effect.logError),
Effect.ignore,
Effect.provide(PrettyLogger),
Effect.runPromise,
);
};

View file

@ -1,10 +0,0 @@
import type { ArtWork, DiaryEntry } from "@/db/schemas";
import type { TmdbMovieSearchResponseResult } from "../apis/tmdb/schemas";
/** Page de réponse de l'API TMDB avec les données locales correspondantes. */
export interface TmdbDataWithLocalData {
artWork?: ArtWork;
entry?: DiaryEntry;
tmdbData: TmdbMovieSearchResponseResult;
}

4
src/libs/search/types.ts Normal file
View file

@ -0,0 +1,4 @@
import type { Values } from "../utils/types";
import type { ARIA_SORT_VALUES } from "./constants";
export type AriaSortValues = Values<typeof ARIA_SORT_VALUES>;

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import type { Ref } from "vue";
import SearchMediaDialog from "@/components/dialogs/SearchMediaDialog.vue";
import LastAddedEntry from "@/components/LastAddedEntry.vue";
import SearchMediaDialog from "@/components/SearchMediaDialog.vue";
import { onMounted, ref } from "vue";
const toggleDialogStateRef = (stateRef: Ref<boolean, boolean>) => () => {
stateRef.value = !stateRef.value;
};
@ -24,6 +25,7 @@
<section id="last-watched-media" class="stack">
<h2>Derniers médias regardés</h2>
<Suspense>
<LastAddedEntry> </LastAddedEntry>
<template #fallback>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import type { NonEmptyArray } from "effect/Array";
import type { Ref } from "vue";
import EditEntryDialog from "@/components/dialogs/EditEntryDialog.vue";
import ErrorMessage from "@/components/ErrorMessage.vue";
import LoadingMessage from "@/components/LoadingMessage.vue";
import TmdbSearchResults from "@/components/TmdbSearchResults.vue";
@ -13,35 +13,36 @@
TmdbMovieSearchResponseResult,
} from "@/libs/apis/tmdb/schemas.ts";
import { MergedTmdbLocalData, SearchPageQueryParams } from "@/libs/search/schemas.ts";
import Search from "@/libs/search/search.ts";
import { updateUrlQueryFromFormData } from "@/libs/search/search.ts";
import { getCurrentYear } from "@/libs/utils/dates.ts";
import { getOrUndefined } from "@/libs/utils/effects.ts";
import { PrettyLogger } from "@/services/logger.ts";
import { ReadApi } from "@/services/read-api.ts";
import { RuntimeClient } from "@/services/runtime-client.ts";
import { TmdbApi } from "@/services/tmdb-api.ts";
import { Array as Arr, Effect, pipe, Schema } from "effect";
import { Effect, pipe, Schema } from "effect";
import { computed, onMounted, ref, useTemplateRef, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
// États
/** L'année courante pour la limite supérieure du champs Année de la recherché. */
/** Année courante pour la limite supérieure du champs « Année » de la recherche. */
const currentYear: number = getCurrentYear();
const route = useRoute();
const router = useRouter();
/** Effet des paramètres validés de la route. */
/** Effet dérivé des paramètres validés de la route. */
const routeQueryParams = computed(() => Schema.decodeUnknown(SearchPageQueryParams)(route.query));
/** Le formulaire de recherche. */
/** L'Élément DOM du formulaire de recherche. */
const form = useTemplateRef("form");
/** Les valeurs du formulaire de recherche. */
/** Valeurs du formulaire de recherche. */
const searchFormData: Ref<SearchPageQueryParams | undefined> = ref<SearchPageQueryParams>();
/** Le retour de la requête de recherche de films auprès de l'API TMDB. */
/** Retour de la requête de recherche de films auprès de l'API TMDB. */
const search: Ref<TmdbMovieSearchResponse | undefined> = ref<TmdbMovieSearchResponse>();
/** Données complètes de la recherche avec les données TMDB et locales. */
const searchData: Ref<Map<number, MergedTmdbLocalData>> = ref(new Map<number, MergedTmdbLocalData>());
/** État du chargement de la requête auprès de l'API TMDB. */
@ -51,25 +52,11 @@
/** Message affiché à l'Utilisateur. */
const message: Ref<string> = ref("");
const editedEntry: Ref<MergedTmdbLocalData | undefined> = ref();
// Fonctions
const updateUrlQuery = async (event?: Event): Promise<void> => {
event?.preventDefault();
await pipe(
Effect.fromNullable(form.value),
Effect.andThen((form: HTMLFormElement) => new FormData(form)),
Effect.andThen((searchFormData: FormData) => Search.formDataToRecord(searchFormData)),
// Met à jour les paramètres de l'URL.
Effect.tap((routeQueryParams: Record<string, NonEmptyArray<string> | string>) =>
router.push({ force: true, query: routeQueryParams })
),
Effect.tapError(Effect.logError),
Effect.ignore,
Effect.provide(PrettyLogger),
Effect.runPromise,
);
};
let updateUrlQuery = updateUrlQueryFromFormData(router, form.value);
const resetInitialState = async (event: Event): Promise<void> => {
event.preventDefault();
@ -132,6 +119,15 @@
Effect.runPromise,
);
const toggleEntryDialog = (tmdbId?: number) => {
if (!tmdbId) {
editedEntry.value = undefined;
return;
}
editedEntry.value = searchData.value.get(tmdbId);
};
// Cycles
watch(search, async (): Promise<void> => {
@ -140,7 +136,7 @@
const results = search.value?.results ?? [];
const readApi = yield* ReadApi;
void results.map((result: TmdbMovieSearchResponseResult) =>
void results.map((result: TmdbMovieSearchResponseResult, index: number) =>
Effect.gen(function*() {
const entry = yield* pipe(
readApi.getEntryByTmdbId(result.id),
@ -152,6 +148,7 @@
effect => getOrUndefined(effect),
);
// TODO: Uniformiser la casse des propriétés.
searchData.value.set(
result.id,
yield* Schema.decodeUnknown(MergedTmdbLocalData)(
@ -165,6 +162,7 @@
entryStateId: entry?.stateId,
genre_ids: result.genre_ids,
original_language: result.original_language,
original_result_index: index,
original_title: result.original_title,
overview: result.overview,
popularity: result.popularity,
@ -185,6 +183,7 @@
onMounted(() => {
console.debug("SearchPage.vue -- Mounted");
updateUrlQuery = updateUrlQueryFromFormData(router, form.value);
});
</script>
@ -251,9 +250,11 @@
<LoadingMessage v-if="isLoading">Récupération des résultats</LoadingMessage>
<ErrorMessage v-if="isErrored">{{ message }}</ErrorMessage>
<TmdbSearchResults v-else :search-data="searchData"></TmdbSearchResults>
<TmdbSearchResults v-else :search-data="searchData" @entry-dialog-wanted="toggleEntryDialog"></TmdbSearchResults>
</section>
</div>
<EditEntryDialog v-if="editedEntry" :entry-data="editedEntry" @dialog-hidden="toggleEntryDialog()"></EditEntryDialog>
</template>
<style scoped lang="css">

27
src/services/images.ts Normal file
View file

@ -0,0 +1,27 @@
import { asInt } from "@thi.ng/color-palettes";
import { ARGB8888, canvasFromPixelBuffer, defIndexed, imageFromURL, intBufferFromImage } from "@thi.ng/pixel";
import { ATKINSON, ditherWith } from "@thi.ng/pixel-dither";
import { Data, Effect, pipe } from "effect";
class ImagesError extends Data.TaggedError("ImagesError")<{ cause: unknown }> {}
export class Images extends Effect.Service<Images>()("Images", {
effect: Effect.gen(function*() {
return {
ditherImage: (image: HTMLImageElement, parent?: HTMLElement) =>
Effect.gen(function*() {
const buf = intBufferFromImage(image, ARGB8888).scale(0.8, "cubic");
const theme = defIndexed(asInt(["salmon", "black"]));
const ditheredBuf = ditherWith(ATKINSON, buf.copy(), {}).as(theme);
const canvas = canvasFromPixelBuffer(ditheredBuf, parent, { pixelated: true });
return canvas;
}),
imageFromUrl: (url: URL) =>
pipe(
Effect.tryPromise(() => imageFromURL(url.toString())),
Effect.mapError(e => new ImagesError({ cause: e.message })),
),
};
}),
}) {}

View file

@ -1,6 +1,6 @@
import type { SQLocalDrizzle } from "sqlocal/drizzle";
import v0000 from "@/db/drizzle/0000_open_the_twelve.sql?raw";
import v0000 from "@/db/drizzle/0000_unusual_karen_page.sql?raw";
import { Data, Effect } from "effect";
import { LocalSqlite } from "./db";

View file

@ -1,6 +1,7 @@
import { Layer, ManagedRuntime } from "effect";
import { LocalSqlite } from "./db";
import { Images } from "./images";
import { PrettyLogger } from "./logger";
import { Migrations } from "./migrations";
import { ReadApi } from "./read-api";
@ -12,6 +13,7 @@ const MainLayer = Layer.mergeAll(
Migrations.Default,
ReadApi.Default,
TmdbApi.Default,
Images.Default,
).pipe(Layer.provide(PrettyLogger));
export const RuntimeClient = ManagedRuntime.make(MainLayer);

View file

@ -2,8 +2,6 @@ html {
box-sizing: border-box;
block-size: 100%;
text-size-adjust: none;
text-size-adjust: none;
text-size-adjust: none;
tab-size: 2;
color-scheme: dark light;
interpolate-size: allow-keywords;
@ -48,7 +46,7 @@ body {
clip-path: inset(50%);
}
:where([hidden]), :where([aria-hidden="true"]) {
:where([hidden]) {
display: none;
}

View file

@ -14,11 +14,11 @@
inline-size: fit-content;
}
:where(a) {
:where(a:not([class])) {
text-decoration: underline dashed;
text-decoration-skip-ink: all;
.external {
&.external {
text-decoration: underline solid;
}
}

View file

@ -1,4 +1,4 @@
button {
button:not(.button-invisible) {
--button-background-color: var(--root-background-color);
--button-border-color: var(--root-text-color);
--button-font-weight: 100;

View file

@ -1 +0,0 @@
{"root":["./src/router/typed-routes.d.ts","./eslint.config.mts","./vite.config.mts","./cfg/drizzle.config.ts","./cfg/eslint-css.config.mts","./cfg/knip.config.ts","./cfg/prettier.config.mjs","./cfg/stylelint.config.mjs","./src/main.ts","./src/vite-env.d.ts","./src/db/schemas.ts","./src/db/schemas/constants.ts","./src/db/schemas/entries.ts","./src/db/schemas/works.ts","./src/libs/apis/clients.ts","./src/libs/apis/requests.ts","./src/libs/apis/routes.ts","./src/libs/apis/tmdb/constants.ts","./src/libs/apis/tmdb/schemas.ts","./src/libs/search/schemas.ts","./src/libs/search/search.ts","./src/libs/types/events.ts","./src/libs/utils/dates.ts","./src/libs/utils/effects.ts","./src/libs/utils/types.d.ts","./src/router/index.ts","./src/services/db.ts","./src/services/logger.ts","./src/services/migrations.ts","./src/services/read-api.ts","./src/services/runtime-client.ts","./src/services/tmdb-api.ts"],"errors":true,"version":"5.7.3"}