diff --git a/package-lock.json b/package-lock.json index 07335dc9c..dc821fd8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-ui", - "version": "5.0.0-2", + "version": "5.0.0-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-ui", - "version": "5.0.0-2", + "version": "5.0.0-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2096,18 +2096,18 @@ } }, "node_modules/@codemirror/state": { - "version": "6.7.5", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.5.tgz", - "integrity": "sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==", + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "node_modules/@codemirror/view": { - "version": "6.43.12", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.12.tgz", - "integrity": "sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==", + "version": "6.43.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.7.0", @@ -3005,9 +3005,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", "devOptional": true, "license": "MIT", "engines": { @@ -3253,9 +3253,9 @@ "license": "MIT" }, "node_modules/@lezer/css": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.6.tgz", - "integrity": "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==", + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.8.tgz", + "integrity": "sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==", "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", @@ -5382,17 +5382,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", - "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/type-utils": "8.70.0", - "@typescript-eslint/utils": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -5405,15 +5405,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.0", + "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", - "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", "dev": true, "license": "MIT", "engines": { @@ -5421,16 +5421,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", - "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3" }, "engines": { @@ -5446,14 +5446,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", - "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.0", - "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", "debug": "^4.4.3" }, "engines": { @@ -5468,14 +5468,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", - "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0" + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5486,9 +5486,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", - "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", "dev": true, "license": "MIT", "engines": { @@ -5503,15 +5503,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", - "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5528,9 +5528,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", - "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", "dev": true, "license": "MIT", "engines": { @@ -5542,16 +5542,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", - "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.0", - "@typescript-eslint/tsconfig-utils": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5583,16 +5583,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", - "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0" + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5607,13 +5607,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", - "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/types": "8.70.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -6584,9 +6584,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.6.tgz", - "integrity": "sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -7511,9 +7511,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.433", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", - "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", + "version": "1.5.436", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.436.tgz", + "integrity": "sha512-iG79/xCF3iThADicbgbES2+liFYmTvelo7jWcEfg6lQ+Adrh7zShrXSg2ZdEDLbKjJ298kGAKMnnzVWobp0L+g==", "dev": true, "license": "ISC" }, @@ -13223,22 +13223,22 @@ } }, "node_modules/tldts": { - "version": "7.4.13", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", - "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "version": "7.4.14", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz", + "integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==", "devOptional": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.13" + "tldts-core": "^7.4.14" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.13", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", - "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "version": "7.4.14", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz", + "integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==", "devOptional": true, "license": "MIT" }, @@ -13509,16 +13509,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", - "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.0", - "@typescript-eslint/parser": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0" + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index eafba1e3d..ff3e60785 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-ui", - "version": "5.0.0-2", + "version": "5.0.0-3", "description": "UI library for Solid applications", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", diff --git a/src/components/file-explorer-header/FileExplorerHeader.styles.css b/src/components/file-explorer-header/FileExplorerHeader.styles.css new file mode 100644 index 000000000..866bd8cbc --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeader.styles.css @@ -0,0 +1,13 @@ +:host { + header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 6px; + flex-wrap: nowrap; + width: 100%; + text-align: right; + padding: 10px; + border-bottom: 1px solid var(--solid-ui-color-gray-200, #cbd5e1); + } +} diff --git a/src/components/file-explorer-header/FileExplorerHeader.ts b/src/components/file-explorer-header/FileExplorerHeader.ts new file mode 100644 index 000000000..5ca7e23c9 --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeader.ts @@ -0,0 +1,108 @@ +import { sym } from 'rdflib' +import { html, nothing } from 'lit' +import { customElement, property, state } from 'lit/decorators.js' +import { consume } from '@lit/context' +import { WebComponent } from '@/lib/components' +import { fileExplorerContext, type FileExplorerContext } from '@/lib/file-explorer/context' +import { storeContext, DEFAULT_STORE } from '@/lib/store/context' +import type { LiveStore } from 'rdflib' +import '~icons/lucide/share-2' +import '~icons/lucide/pencil' +import '~icons/lucide/ellipsis-vertical' +import styles from './FileExplorerHeader.styles.css' +import './FileExplorerHeaderSummary' +import './FileExplorerHeaderControls' +import { type PaneIcon } from './types' +import { type FileExplorerResourceMetadata } from './types' +import { solidLogicSingleton } from 'solid-logic' + +@customElement('file-explorer-header') +export default class FileExplorerHeader extends WebComponent { + static styles = styles + + private _loadedMetadataKey: string | undefined + + @consume({ context: fileExplorerContext, subscribe: true }) + accessor fileExplorerContext: FileExplorerContext = undefined as unknown as FileExplorerContext + + @consume({ context: storeContext, subscribe: true }) + accessor store: LiveStore = DEFAULT_STORE + + @property({ attribute: false }) + accessor menuItems: Array<{ label: string, action: (event: Event) => void }> = [] + + @property({ attribute: false }) + accessor paneIcon: PaneIcon = undefined as unknown as PaneIcon + + @state() + accessor responseMetadata: Pick & { canDelete: boolean } = { + modified: undefined, + isPublic: false, + canEdit: false, + canDelete: false, + aclUri: undefined + } + + private getDefaultResponseMetadata (): Pick & { canDelete: boolean } { + return { + modified: undefined, + isPublic: false, + canEdit: false, + canDelete: false, + aclUri: undefined + } + } + + private get shouldRenderHeader () { + return !this.fileExplorerContext?.pane?.mintClass + } + + protected updated () { + const subjectUri = this.fileExplorerContext.subjectUri + const metadataKey = `${subjectUri}:${this.fileExplorerContext.resourceRevision ?? 0}` + + if (this.store && subjectUri && this._loadedMetadataKey !== metadataKey) { + this._loadedMetadataKey = metadataKey + this.loadResponseMetadata() + } + } + + private async loadResponseMetadata () { + if (!this.store || !this.fileExplorerContext.subjectUri) return + + try { + const metadata = await solidLogicSingleton.resource.fetchMetadata(sym(this.fileExplorerContext.subjectUri)) + this.responseMetadata = { + modified: metadata.modified, + isPublic: metadata.access.isPublic, + canEdit: metadata.access.canEdit, + canDelete: metadata.access.canDelete, + aclUri: metadata.aclUri + } + } catch (error) { + this.responseMetadata = this.getDefaultResponseMetadata() + console.warn('Failed to load response metadata', error) + } + } + + render () { + if (!this.shouldRenderHeader) { + return html`${nothing}` + } + + return html` +
+ + +
+ ` + } +} diff --git a/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css b/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css new file mode 100644 index 000000000..a7535c963 --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css @@ -0,0 +1,57 @@ +:host { + flex: 0 1 auto; + min-width: 0; + + div { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 6px; + flex: 0 0 auto; + text-align: right; + } + + /* generative ai */ + div > * { + flex: 0 1 auto; + } + + /* TODO: look into what colors to use this is just to show */ + .dirtyIndicator { + display: inline-flex; + align-items: center; + border: 1px solid #F59E0B; + color: #92400E; + background: #FFFBEB; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + line-height: 1; + padding: 4px 8px; + white-space: nowrap; + } + + icon-lucide-share-2, + icon-lucide-pencil { + width: 16px; + height: 16px; + } + + @media (max-width: 900px) { + div { + padding-right: 3px; + } + } + + @media (max-width: 600px) { + div { + justify-content: flex-start; + gap: 5px; + } + + .file-explorer-header-access-button, + .file-explorer-header-edit-button { + display: none; + } + } +} \ No newline at end of file diff --git a/src/components/file-explorer-header/FileExplorerHeaderControls.ts b/src/components/file-explorer-header/FileExplorerHeaderControls.ts new file mode 100644 index 000000000..9cdb43294 --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderControls.ts @@ -0,0 +1,206 @@ +import { html, nothing } from 'lit' +import type { PropertyValues } from 'lit' +import { customElement, property, state } from 'lit/decorators.js' +import { WebComponent } from '@/lib/components' +import '@/components/button' +import '@/components/resource-actions-menu' +import { consume } from '@lit/context' +import { fileExplorerContext, type FileExplorerContext } from '@/lib/file-explorer/context' +import { storeContext, DEFAULT_STORE } from '@/lib/store/context' +import type { LiveStore } from 'rdflib' +import { sym } from 'rdflib' +import { solidLogicSingleton } from 'solid-logic' +import { buildResourceActionsMenuBindings } from '@/lib/resource-actions-menu' +import { loadDiscoveryState, toggleDiscoveryState } from '@/lib/discovery' +import styles from './FileExplorerHeaderControls.styles.css' + +@customElement('file-explorer-header-controls') +export default class FileExplorerHeaderControls extends WebComponent { + static styles = styles + + private mobileMediaQuery: MediaQueryList | undefined + private readonly mobileQuery = '(max-width: 600px)' + private readonly handleMobileMediaChange = (event: MediaQueryListEvent) => { + this.isMobile = event.matches + } + + private handleMobileAccessClick = () => { + const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined + + if (subject && this.fileExplorerContext.openPane) { + this.fileExplorerContext.openPane(subject, 'sharing') + return + } + + this.fileExplorerContext.handleAccessClick?.() + } + + private handleDeleteClick = async () => { + const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined + if (!subject || !this.fileExplorerContext.deleteResource) return + + try { + await this.fileExplorerContext.deleteResource(subject) + } catch (error) { + console.error('[file-explorer-header.deleteResource] failed', error) + globalThis.alert(this.deleteLabel === 'Permanently Delete' + ? 'Error deleting resource' + : 'Error moving resource to Trash') + } + } + + private async refreshDiscoveryState () { + const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined + const discoverClass = this.fileExplorerContext.discoverClass + + if (!subject || !discoverClass) { + this.discoveryState = undefined + return + } + + try { + this.discoveryState = await loadDiscoveryState(subject) + } catch (error) { + this.discoveryState = undefined + console.warn('Failed to load discovery state', error) + } + } + + private handleDiscoverPublicClick = async () => { + const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined + const discoverClass = this.fileExplorerContext.discoverClass + + if (!subject || !discoverClass) return + + this.discoveryState = await toggleDiscoveryState(subject, discoverClass, 'public', this.discoveryState) + } + + private handleDiscoverPrivateClick = async () => { + const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined + const discoverClass = this.fileExplorerContext.discoverClass + + if (!subject || !discoverClass) return + + this.discoveryState = await toggleDiscoveryState(subject, discoverClass, 'private', this.discoveryState) + } + + @consume({ context: fileExplorerContext, subscribe: true }) + accessor fileExplorerContext: FileExplorerContext = undefined as unknown as FileExplorerContext + + @consume({ context: storeContext, subscribe: true }) + accessor store: LiveStore = DEFAULT_STORE + + @property({ attribute: false }) + accessor menuItems: Array<{ label: string, action: (event: Event) => void, icon?: HTMLElement }> = [] + + @property({ type: Boolean }) + accessor canEdit: boolean = false + + @property({ type: Boolean }) + accessor canDelete: boolean = false + + @state() + accessor isMobile = typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(max-width: 600px)').matches + : false + + @state() + accessor discoveryState: { public: boolean, private: boolean } | undefined = undefined + + connectedCallback () { + super.connectedCallback() + + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return + } + + this.mobileMediaQuery = window.matchMedia(this.mobileQuery) + this.isMobile = this.mobileMediaQuery.matches + this.mobileMediaQuery.addEventListener('change', this.handleMobileMediaChange) + } + + disconnectedCallback () { + this.mobileMediaQuery?.removeEventListener('change', this.handleMobileMediaChange) + this.mobileMediaQuery = undefined + super.disconnectedCallback() + } + + protected willUpdate (changedProperties: PropertyValues) { + super.willUpdate(changedProperties) + + if ( + changedProperties.has('fileExplorerContext') || + changedProperties.has('menuItems') + ) { + void this.refreshDiscoveryState() + } + } + + private getEditTooltip () { + if (!this.fileExplorerContext.paneSupportsEditing) return 'Not Supported' + if (!this.canEdit) return 'No Access' + return 'Edit' + } + + private get deleteLabel () { + const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined + return subject?.dir()?.uri.endsWith('/Trash/') ? 'Permanently Delete' : 'Move to Trash' + } + + private renderDirtyIndicator () { + if (!this.fileExplorerContext.edit?.isDirty) return nothing + + return html`Unsaved` + } + + render () { + const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined + const isContainerResource = subject ? solidLogicSingleton.resource.isContainer(subject) : false + const resourceActions = buildResourceActionsMenuBindings({ + subject, + discoveryState: this.discoveryState, + handleAccessClick: this.isMobile ? this.handleMobileAccessClick : undefined, + canDelete: this.canDelete && !!this.fileExplorerContext.deleteResource, + handleDeleteClick: this.handleDeleteClick, + handleDiscoverPublicClick: this.fileExplorerContext.discoverClass ? this.handleDiscoverPublicClick : undefined, + handleDiscoverPrivateClick: this.fileExplorerContext.discoverClass ? this.handleDiscoverPrivateClick : undefined, + }) + + return html` +
+ ${this.renderDirtyIndicator()} + ${!this.isMobile + ? html` + + + + + + + ` + : nothing} + +
+ ` + } +} diff --git a/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css b/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css new file mode 100644 index 000000000..92088121c --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css @@ -0,0 +1,148 @@ + :host { + .file-explorer-header-summary { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 6px; + flex-wrap: wrap; + flex: 1 1 auto; + min-width: 0; + text-align: left; + } + + h1 { + color: var(--solid-ui-color-gray-700, #364153); + font-size: var(--solid-ui-font-size-xl, 1.25rem); /* while in expand; will go to 2xl when new design is complete */ + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + } + + .resource-info { + display: flex; + flex-direction: column; + min-width: 0; + } + + .container-info { + display: flex; + flex-direction: row; + gap: 10px; + min-width: 0; + } + + .pane-icon { + display: flex; + width: 40px; + height: 40px; + padding: 7.5px 7px 6.5px 7px; + justify-content: center; + align-items: center; + aspect-ratio: 1/1; + /* border-radius: 100px; */ + /* background: var(--slate-600, #45556C); */ + } + + .pane-icon img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } + + p { + display: flex; + align-items: center; + gap: 5px; + color: var(--solid-ui-gray-500, #6A7282); + font-size: var(--solid-ui-font-size-xs, .75rem); + font-weight: 500; + } + + .public { + display: flex; + padding: 2px 5px; + justify-content: center; + align-items: center; + gap: 3px; + color: var(--blue-400, #51A2FF); + border-radius: 5px; + background: var(--blue-50, #EFF6FF); + } + + icon-lucide-globe { + stroke-width: 1.021px; + stroke: var(--blue-400, #51A2FF); + } + + .private { + display: flex; + padding: 2px 5px; + justify-content: center; + align-items: center; + gap: 3px; + color: var(--yellow-600, #D08700); + border-radius: 5px; + background: var(--yellow-100, #FEF9C2); + } + + icon-lucide-lock-keyhole { + stroke-width: 1.021px; + stroke: var(--yellow-600, #D08700); + } + + icon-lucide-globe, + icon-lucide-lock-keyhole { + width: 0.875rem; + height: 0.875rem; + } + + .resource-date { + font-size: inherit; + } + + icon-lucide-arrow-left { + width: 1.125rem; + height: 1.125rem; + } + + @media (max-width: 600px) { + .file-explorer-header-summary { + gap: 7px; + } + + h1 { + font-size: 14px; + } + + .pane-icon { + width: 18.2px; + height: 18.2px; + flex-shrink: 0; + aspect-ratio: 1 / 1; + padding: 0; + } + + p { + font-size: 14px; + } + + .resource-date { + font-size: 10px; + } + + .public, + .private { + font-size: 9px; + } + + icon-lucide-arrow-left { + width: 15px; + height: 18px; + flex-shrink: 0; + aspect-ratio: 5 / 6; + } + } +} \ No newline at end of file diff --git a/src/components/file-explorer-header/FileExplorerHeaderSummary.ts b/src/components/file-explorer-header/FileExplorerHeaderSummary.ts new file mode 100644 index 000000000..5365518d2 --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderSummary.ts @@ -0,0 +1,157 @@ +import { sym } from 'rdflib' +import { html } from 'lit' +import { customElement, property, query, state } from 'lit/decorators.js' +import { consume } from '@lit/context' +import { WebComponent } from '@/lib/components' +import { fileExplorerContext, type FileExplorerContext } from '@/lib/file-explorer/context' +import { storeContext, DEFAULT_STORE } from '@/lib/store/context' +import { solidLogicSingleton } from 'solid-logic' +import type { LiveStore } from 'rdflib' +import styles from './FileExplorerHeaderSummary.styles.css' +import { getContainerItemCount } from './metadata' +import type { FileExplorerResourceMetadata, PaneIcon } from './types' +import '@/components/button' +import { makeDraggable } from '@/widgets/dragAndDrop.js' +import { label as resourceLabel } from '@/utils/label' +import '~icons/lucide/globe' +import '~icons/lucide/lock-keyhole' +import '~icons/lucide/arrow-left' +import '~icons/lucide/folder' + +@customElement('file-explorer-header-summary') +export default class FileExplorerHeaderSummary extends WebComponent { + static styles = styles + + private _draggableSubjectUri: string | undefined + private _resolvedPaneIconFor: PaneIcon | undefined + + @consume({ context: fileExplorerContext, subscribe: true }) + accessor fileExplorerContext: FileExplorerContext = undefined as unknown as FileExplorerContext + + @consume({ context: storeContext, subscribe: true }) + accessor store: LiveStore = DEFAULT_STORE + + @property({ attribute: false }) + accessor paneIcon: PaneIcon | undefined + + @property({ attribute: false }) + accessor onBackClick: (() => void) | undefined + + @property({ attribute: false }) + accessor responseMetadata: Pick = { + modified: undefined, + isPublic: false + } + + @state() + accessor resolvedPaneIcon: string | undefined = undefined + + @query('h1') + private accessor titleHeading: HTMLHeadingElement | null = null + + private formatModifiedDate (modified: string | undefined) { + if (!modified) return '' + + const date = new Date(modified) + if (Number.isNaN(date.getTime())) return modified + + const parts = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true + }).formatToParts(date) + + const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)?.value ?? '' + const day = getPart('day') + const month = getPart('month') + const year = getPart('year') + const hour = getPart('hour') + const minute = getPart('minute') + const dayPeriod = getPart('dayPeriod').toUpperCase() + + return `${day} ${month}, ${year} at ${hour}:${minute} ${dayPeriod}` + } + + protected updated () { + if (this.paneIcon !== undefined && this._resolvedPaneIconFor !== this.paneIcon) { + this._resolvedPaneIconFor = this.paneIcon + this.resolvePaneIcon() + } + + if (!this.titleHeading || !this.fileExplorerContext?.subjectUri || this._draggableSubjectUri === this.fileExplorerContext.subjectUri) return + + makeDraggable(this.titleHeading, sym(this.fileExplorerContext.subjectUri)) + this._draggableSubjectUri = this.fileExplorerContext.subjectUri + } + + private async resolvePaneIcon () { + try { + if (this.paneIcon == null) { + this.resolvedPaneIcon = undefined + return + } + + const icon = await this.paneIcon + this.resolvedPaneIcon = icon ?? undefined + } catch (error) { + this.resolvedPaneIcon = undefined + console.warn('file-explorer-header-summary: failed to resolve pane icon', error) + } + } + + private renderContainerResourceHeader (label: string, isPublic: boolean) { + const itemCount = getContainerItemCount(this.store, this.fileExplorerContext?.subjectUri) ?? 0 + return html` +
+

+ ${label} +

+

+ ${itemCount} items + ${isPublic + ? html`` + : html``} +

+
+ ` + } + + private renderResourceHeader (label: string, isPublic: boolean) { + const modified = this.formatModifiedDate(this.responseMetadata.modified) + + return html` +
+

+ ${label} +

+

${modified} ${isPublic ? html` Public` : html` Private`}

+
+ ` + } + + render () { + const subject = this.fileExplorerContext?.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined + const label = subject ? resourceLabel(subject) : '' + const isPublic = this.responseMetadata.isPublic + const isContainerResource = subject ? solidLogicSingleton.resource.isContainer(subject) : false + + return html` +
+ this.onBackClick?.()} + title="Back" + > + + + + ${this.resolvedPaneIcon ? html`` : ''} + + ${isContainerResource ? this.renderContainerResourceHeader(label, isPublic) : this.renderResourceHeader(label, isPublic)} +
+ ` + } +} diff --git a/src/components/file-explorer-header/FileExplorerProvider.styles.css b/src/components/file-explorer-header/FileExplorerProvider.styles.css new file mode 100644 index 000000000..758dc376d --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerProvider.styles.css @@ -0,0 +1,20 @@ +:host { + display: block; + width: 100%; + min-width: 0; + + .file-explorer-provider { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + } + + slot.pane { + display: block; + flex: 1 1 auto; + width: 100%; + min-width: 0; + overflow: auto; + } +} \ No newline at end of file diff --git a/src/components/file-explorer-header/FileExplorerProvider.ts b/src/components/file-explorer-header/FileExplorerProvider.ts new file mode 100644 index 000000000..3e74b673d --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerProvider.ts @@ -0,0 +1,277 @@ +import { html, nothing, type PropertyValues } from 'lit' +import type { NamedNode } from 'rdflib' +import { DataBrowserContext, type PaneDefinition } from 'pane-registry' +import { WebComponent } from '@/lib/components' +import { AJARImage } from '@/utils' +import { consume, provide } from '@lit/context' +import { fileExplorerContext, type FileExplorerContext } from '@/lib/file-explorer/context' +import { DEFAULT_DISCOVER_CLASS } from '@/lib/discovery' +import { customElement, property, state } from 'lit/decorators.js' +import './FileExplorerHeader' +import styles from './FileExplorerProvider.styles.css' +import '~icons/lucide/share-2' +import '~icons/lucide/user' +import '~icons/lucide/users' + +function createFileExplorerContextValue (value: { + subjectUri: string | undefined + pane?: PaneDefinition + soloPane?: boolean + onBack?: () => void + openPane?: (subject: NamedNode, paneName: string) => void + handleAccessClick?: () => void + deleteResource?: (subject: NamedNode) => Promise + resourceRevision?: number + discoverClass?: NamedNode + paneSupportsEditing?: boolean + edit?: { + onEdit?: () => void + isDirty?: boolean + updateDirtyState?: (dirty: boolean) => void + } +}): FileExplorerContext { + return { + subjectUri: value.subjectUri, + pane: value.pane, + soloPane: value.soloPane, + onBack: value.onBack, + openPane: value.openPane, + handleAccessClick: value.handleAccessClick, + deleteResource: value.deleteResource, + resourceRevision: value.resourceRevision, + discoverClass: value.discoverClass, + paneSupportsEditing: value.paneSupportsEditing, + edit: value.edit + } +} + +@customElement('file-explorer-provider') +export default class FileExplorerProvider extends WebComponent { + static styles = styles + + @property({ attribute: false }) + accessor context: DataBrowserContext | undefined = undefined + + @property({ attribute: false }) + accessor subjectUri: string | undefined = undefined + + @property({ attribute: false }) + accessor relevantPanes: PaneDefinition[] = [] + + @property({ attribute: false }) + accessor pane: PaneDefinition | undefined = undefined + + @property({ attribute: false }) + accessor paneRenderOptions: Record = {} + + @property({ attribute: false }) + accessor showHeader: boolean = true + + @property({ attribute: false }) + accessor handleAccessClick: (() => void) | undefined = undefined + + @property({ attribute: false }) + accessor deleteResource: ((subject: NamedNode) => Promise) | undefined = undefined + + @property({ type: Number }) + accessor resourceRevision: number | undefined = undefined + + @property({ attribute: false }) + accessor soloPane: boolean | undefined = undefined + + @property({ attribute: false }) + accessor openPane: ((subject: NamedNode, paneName: string) => void) | undefined = undefined + + @consume({ context: fileExplorerContext, subscribe: true }) + accessor parentFileExplorerContext: FileExplorerContext = undefined as unknown as FileExplorerContext + + @state() + accessor menuItems: Array<{ label: string, icon?: HTMLElement, action: (event: Event) => void }> = [] + + @state() + accessor isEditing: boolean = false + + @state() + accessor isDirty: boolean = false + + @state() + accessor paneSupportsEditing: boolean = false + + private beginEditingInSourcePane = () => { + const sourceProvider = this.querySelector('source-pane-source-provider') as { + beginEditing?: () => void + } | null + + sourceProvider?.beginEditing?.() + } + + @state() + accessor edit = { + onEdit: this.beginEditingInSourcePane, + isDirty: false, + updateDirtyState: (dirty: boolean) => { + if (this.isDirty === dirty) return + + this.isDirty = dirty + this.edit = { + ...this.edit, + isDirty: dirty + } + this.refreshFileExplorerContextValue() + } + } + + @state() + accessor onBack: (() => void) | undefined = undefined + + @provide({ context: fileExplorerContext }) + accessor fileExplorerContextValue: FileExplorerContext = createFileExplorerContextValue({ + subjectUri: this.subjectUri, + pane: this.pane, + soloPane: this.soloPane, + onBack: this.onBack ?? this.parentFileExplorerContext?.onBack, + openPane: this.openPane, + handleAccessClick: this.handleAccessClick, + deleteResource: this.deleteResource ?? this.parentFileExplorerContext?.deleteResource, + resourceRevision: this.resourceRevision ?? this.parentFileExplorerContext?.resourceRevision, + discoverClass: this.pane?.mintClass ?? this.relevantPanes.find((pane) => pane.mintClass)?.mintClass ?? this.parentFileExplorerContext?.discoverClass ?? DEFAULT_DISCOVER_CLASS, + paneSupportsEditing: false, + edit: this.edit + }) + + openSelectedPane (pane: PaneDefinition) { + const store = this.context?.session.store + if (!store || !this.subjectUri) return + this.paneSupportsEditing = pane.name === 'source' + this.pane = pane + } + + private getPaneIcon (pane: PaneDefinition | undefined, subject: NamedNode, context: DataBrowserContext | undefined) { + if (!pane) return undefined + + const paneWithIcon = pane as PaneDefinition & { + icon?: string | ((subject: NamedNode, context: DataBrowserContext) => string | Promise) + } + + const icon = typeof paneWithIcon.icon === 'function' + ? (paneWithIcon.icon as any)(subject, context as DataBrowserContext) + : paneWithIcon.icon + return icon + } + + private async getPaneItems (subject: NamedNode, context: DataBrowserContext, relevantPanes: PaneDefinition[]) { + const dom = context.dom + const menuItems = await Promise.all(relevantPanes.map(async pane => { + const label = pane.label(subject, context) ?? '' + + let icon: HTMLElement | undefined + if (pane.name === 'profile') { + icon = dom.createElement('icon-lucide-user') + } else if (pane.name === 'social') { + icon = dom.createElement('icon-lucide-users') + } else { + const paneIcon = await this.getPaneIcon(pane, subject, context) + if (typeof paneIcon === 'string' && paneIcon) { + icon = AJARImage(paneIcon, label, label, dom) + } + } + + icon?.setAttribute('slot', 'left-icon') + + return { + label, + icon, + action: () => this.openPane?.(subject, pane.name) + } + })) + return menuItems + } + + private refreshFileExplorerContextValue () { + this.fileExplorerContextValue = createFileExplorerContextValue({ + subjectUri: this.subjectUri, + pane: this.pane, + soloPane: this.soloPane, + onBack: this.onBack ?? this.parentFileExplorerContext?.onBack, + openPane: this.openPane, + handleAccessClick: this.handleAccessClick, + deleteResource: this.deleteResource ?? this.parentFileExplorerContext?.deleteResource, + resourceRevision: this.resourceRevision ?? this.parentFileExplorerContext?.resourceRevision, + discoverClass: this.pane?.mintClass ?? this.relevantPanes.find((pane) => pane.mintClass)?.mintClass ?? this.parentFileExplorerContext?.discoverClass ?? DEFAULT_DISCOVER_CLASS, + paneSupportsEditing: this.paneSupportsEditing, + edit: this.edit + }) + } + + private async refreshMenuItems () { + const store = this.context?.session.store + if (!store || !this.subjectUri) return + + const subject = store.sym(this.subjectUri) + const menuItems = await this.getPaneItems(subject, this.context as DataBrowserContext, this.relevantPanes) + + this.menuItems = menuItems + } + + protected willUpdate (changedProperties: PropertyValues) { + super.willUpdate(changedProperties) + if (!this.subjectUri) { + throw new Error('The element is missing the required `subjectUri` property.') + } + + if ( + changedProperties.has('subjectUri') || + changedProperties.has('relevantPanes') || + changedProperties.has('context') + ) { + this.refreshMenuItems().catch(error => { + console.warn('Failed to refresh menu items', error) + }) + } + + if (changedProperties.has('pane') || changedProperties.has('subjectUri') || changedProperties.has('context')) { + this.paneSupportsEditing = this.pane?.name === 'source' + } + + if ( + changedProperties.has('context') || + changedProperties.has('subjectUri') || + changedProperties.has('pane') || + changedProperties.has('relevantPanes') || + changedProperties.has('soloPane') || + changedProperties.has('onBack') || + changedProperties.has('parentFileExplorerContext') || + changedProperties.has('openPane') || + changedProperties.has('handleAccessClick') || + changedProperties.has('deleteResource') || + changedProperties.has('resourceRevision') || + changedProperties.has('paneSupportsEditing') || + changedProperties.has('isDirty') + ) { + this.refreshFileExplorerContextValue() + } + } + + render () { + const store = this.context?.session.store + if (!store || !this.subjectUri) { + return html`` + } + + const subject = store.sym(this.subjectUri) + return html` +
+ ${this.showHeader + ? html` + + ` + : nothing} + +
+ ` + } +} diff --git a/src/components/file-explorer-header/index.ts b/src/components/file-explorer-header/index.ts new file mode 100644 index 000000000..4aa733e6f --- /dev/null +++ b/src/components/file-explorer-header/index.ts @@ -0,0 +1,5 @@ +import FileExplorerHeader from './FileExplorerHeader' +import FileExplorerProvider from './FileExplorerProvider' + +export { FileExplorerHeader, FileExplorerProvider } +export default FileExplorerHeader \ No newline at end of file diff --git a/src/components/file-explorer-header/metadata.ts b/src/components/file-explorer-header/metadata.ts new file mode 100644 index 000000000..81250595f --- /dev/null +++ b/src/components/file-explorer-header/metadata.ts @@ -0,0 +1,122 @@ +import { type LiveStore, type NamedNode } from 'rdflib' +import { ACL_LINK } from 'solid-logic' +import ns from '@/lib/ns' +import type { FileExplorerResourceMetadata } from './types' + +function parseWacAllowHeader (headerValue: string | null | undefined) { + const permissions = new Map>() + if (!headerValue) return permissions + + for (const entry of headerValue.split(',')) { + const match = entry.trim().match(/^([A-Za-z]+)\s*=\s*"([^"]*)"$/) + if (match) { + const [, permissionGroup, accessModes] = match + const modes = accessModes.trim().split(/\s+/).filter(Boolean) + permissions.set(permissionGroup.toLowerCase(), new Set(modes.map(mode => mode.toLowerCase()))) + } + } + + return permissions +} + +function deriveAccessFlags (wacAllow: string | null | undefined) { + if (!wacAllow) { + return { canEdit: false, isPublic: false } + } + + const permissions = parseWacAllowHeader(wacAllow) + const userModes = permissions.get('user') ?? new Set() + const publicModes = permissions.get('public') ?? new Set() + + return { + canEdit: userModes.has('write'), + isPublic: publicModes.has('read') || publicModes.has('write') + } +} + +export function getContainerItemCount (store: LiveStore | undefined, subjectUri: string | undefined): number { + if (!store || !subjectUri) return 0 + + const subject = store.sym(subjectUri) + const seen = new Set() + + for (const item of store.each(subject, ns.ldp('contains'))) { + if (item.termType === 'NamedNode') { + const resource = item as NamedNode + const parent = resource.dir() + + if (parent) { + const pathEnd = resource.uri.slice(parent.uri.length) + if ( + !pathEnd.startsWith('.') && + !pathEnd.endsWith('.acl') && + !pathEnd.endsWith('~') + ) { + seen.add(resource.uri) + } + } + } + } + + return seen.size +} + +export function getResponseMetadata (store: LiveStore, subject: NamedNode, response: Response): FileExplorerResourceMetadata { + let contentType: string | undefined + let canEdit = false + let isPublic = false + let eTag: string | undefined + let modified: string | undefined + + if (response.headers && response.headers.get('content-type')) { + contentType = response.headers.get('content-type')?.split(';')[0] ?? undefined + const accessFlags = deriveAccessFlags(response.headers.get('wac-allow')) + + canEdit = accessFlags.canEdit + isPublic = accessFlags.isPublic + eTag = response.headers.get('etag') ?? undefined + modified = store.anyValue(subject as any, ns.dct('modified')) || store.anyValue(subject as any, ns.dc('modified')) || undefined + } else { + const reqs = store.each( + null, + store.sym('http://www.w3.org/2007/ont/link#requestedURI'), + subject + ) + + reqs.forEach((req: any) => { + const responseNode = store.any( + req as any, + store.sym('http://www.w3.org/2007/ont/link#response') + ) + + if (responseNode && responseNode.termType === 'NamedNode') { + contentType = store.anyValue(responseNode as any, ns.httph('content-type')) || undefined + const wacAllow = (store.anyValue(responseNode as any, ns.httph('wac-allow')) as string | undefined) || + (store.anyValue(responseNode as any, ns.httph('WAC-Allow')) as string | undefined) + const accessFlags = deriveAccessFlags(wacAllow) + canEdit = accessFlags.canEdit + isPublic = accessFlags.isPublic + eTag = store.anyValue(responseNode as any, ns.httph('etag')) || undefined + modified = store.anyValue(subject as any, ns.dct('modified')) || store.anyValue(subject as any, ns.dc('modified')) || undefined + } + }) + } + + const aclUri = store.any(subject, ACL_LINK)?.value || undefined + return { contentType, canEdit, isPublic, aclUri, eTag, modified } +} + +export async function fetchResourceMetadata (store: LiveStore, subject: NamedNode): Promise { + const response = await store.fetcher.webOperation('HEAD', subject.uri) + + if (!response.ok) { + throw new Error(`HEAD request failed with status ${response.status}`) + } + + const metadata = getResponseMetadata(store, subject, response) + if (!metadata.contentType) { + throw new Error('No content-type available!') + } + + return metadata +} diff --git a/src/components/file-explorer-header/types.ts b/src/components/file-explorer-header/types.ts new file mode 100644 index 000000000..232366c96 --- /dev/null +++ b/src/components/file-explorer-header/types.ts @@ -0,0 +1,10 @@ +export type PaneIcon = string | Promise | null | undefined + +export type FileExplorerResourceMetadata = { + contentType: string | undefined + canEdit: boolean + isPublic: boolean + aclUri: string | undefined + eTag: string | undefined + modified: string | undefined +} \ No newline at end of file diff --git a/src/components/menu-item/MenuItem.styles.css b/src/components/menu-item/MenuItem.styles.css index 594b8b28e..9f160ab6e 100644 --- a/src/components/menu-item/MenuItem.styles.css +++ b/src/components/menu-item/MenuItem.styles.css @@ -1,4 +1,7 @@ :host { + --solid-ui-menu-item-font-size: var(--solid-ui-font-size-md); + --solid-ui-menu-item-gap: 5px; + & > a, & > div { width: 100%; @@ -6,12 +9,12 @@ position: relative; justify-content: flex-start; align-items: center; - gap: 5px; + gap: var(--solid-ui-menu-item-gap, 5px); padding: 10px; border-radius: 5px; color: var(--solid-ui-color-gray-600); font-weight: 500; - font-size: var(--solid-ui-font-size-md); + font-size: var(--solid-ui-menu-item-font-size, var(--solid-ui-font-size-md)); &[data-selected] { background: var(--solid-ui-color-primary-selected); diff --git a/src/components/menu/Menu.styles.css b/src/components/menu/Menu.styles.css index 34dde081b..b5e6d7621 100644 --- a/src/components/menu/Menu.styles.css +++ b/src/components/menu/Menu.styles.css @@ -1,5 +1,5 @@ wa-dropdown::part(menu) { - min-width: 300px; + min-width: var(--solid-ui-menu-min-width, 300px); box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.35); gap: 2px; } diff --git a/src/components/provider/Provider.ts b/src/components/provider/Provider.ts index 146007065..c0b5f9f2f 100644 --- a/src/components/provider/Provider.ts +++ b/src/components/provider/Provider.ts @@ -2,8 +2,11 @@ import { customElement, WebComponent } from '@/lib/components' import { provide } from '@lit/context' import { html, type PropertyValues } from 'lit' import { property } from 'lit/decorators.js' +import { solidLogicSingleton } from 'solid-logic' import { authContext } from '@/lib/auth' import { SolidAuth, DEFAULT_SIGNUP_URL } from '@/lib/auth' +import { storeContext } from '@/lib/store' +import type { LiveStore } from 'rdflib' import '@/components/dialogs-root' @@ -15,6 +18,9 @@ export default class Provider extends WebComponent { @provide({ context: authContext }) private accessor auth = new SolidAuth() + @provide({ context: storeContext }) + private accessor store: LiveStore = solidLogicSingleton.store + async connectedCallback () { super.connectedCallback() @@ -27,6 +33,11 @@ export default class Provider extends WebComponent { if (changedProperties.has('signupUrl')) { this.auth.signupUrl = this.signupUrl ?? DEFAULT_SIGNUP_URL } + + if (this.store) { + void this.store + } + } protected render () { diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index 0011a7e44..19f845824 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -7,7 +7,7 @@ import ns from '../../lib/ns' import { fetchData, findForm, sortBySequence } from '../../lib/forms/rdfFormsHelper' import { sym, LiveStore } from 'rdflib' import '@/components/rdf-input' -import { DEFAULT_STORE, storeContext, StoreContext } from '@/lib/forms/store/StoreContext' +import { DEFAULT_STORE, storeContext } from '@/lib/store' const urlConverter = { fromAttribute (value: string | null): URL | null { @@ -31,13 +31,13 @@ const hrefFromUrlValue = (value: URL | null): string => @customElement('solid-ui-rdf-form') export default class RDFForm extends WebComponent { @consume({ context: storeContext, subscribe: true }) - private accessor storeContext: StoreContext = DEFAULT_STORE + private accessor store: LiveStore = DEFAULT_STORE @property({ attribute: false }) accessor passedInStore: LiveStore | null = null private get currentStore (): LiveStore { - return this.passedInStore ?? this.storeContext.store + return this.passedInStore ?? this.store } @state() diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index 3a551b1c5..ab8a7eead 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -6,9 +6,10 @@ import { Literal, NamedNode, st } from 'rdflib' import { label } from '../../utils' import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper' import { FieldParamsObject, fieldParams as fieldTypeParams, InputType } from '../../lib/forms/fieldParams' -import { DEFAULT_STORE, storeContext, StoreContext } from '@/lib/forms/store/StoreContext' +import { DEFAULT_STORE, storeContext } from '@/lib/store' import { consume } from '@lit/context' import '@/components/input' +import type { LiveStore } from 'rdflib' @customElement('solid-ui-rdf-input') export default class RDFInput extends WebComponent { @@ -21,7 +22,7 @@ export default class RDFInput extends WebComponent { // dataSubject points to the data resource containing the value @consume({ context: storeContext, subscribe: true }) - private accessor storeContext: StoreContext = DEFAULT_STORE + private accessor store: LiveStore = DEFAULT_STORE @property({ attribute: false, type: Object }) accessor formSubject: NamedNode | null = null @@ -55,7 +56,7 @@ export default class RDFInput extends WebComponent { const inputLabel = this.getInputLabel(this.formSubject, uiPropertyTerm, formDocument) const readonly = this.getReadOnly(this.readonly, this.formSubject, formDocument) - const fieldType = this.formSubject ? mostSpecificClassURI(this.storeContext.store, this.formSubject) : undefined + const fieldType = this.formSubject ? mostSpecificClassURI(this.store, this.formSubject) : undefined const params = fieldType ? fieldTypeParams[fieldType] ?? {} : {} const inputType: InputType = params.type ?? 'text' @@ -84,19 +85,19 @@ export default class RDFInput extends WebComponent { private getFormProperty (subject: NamedNode | null | undefined, property: NamedNode, graph?: any): NamedNode | undefined { if (!subject) return undefined - return this.storeContext.store.any(subject, property, null, graph) as NamedNode | undefined + return this.store.any(subject, property, null, graph) as NamedNode | undefined } private getInputLabel (formFieldSubject: NamedNode | null | undefined, uiPropertyTerm?: NamedNode, graph?: any): string { if (!formFieldSubject) return '' - const uiLabel = this.storeContext.store.any(formFieldSubject, ns.ui('label'), null, graph) + const uiLabel = this.store.any(formFieldSubject, ns.ui('label'), null, graph) const propertyLabel = uiPropertyTerm ? label(uiPropertyTerm, true) : '' return uiLabel ? uiLabel.value : propertyLabel } private getReadOnly (readonly: boolean, formFieldSubject?: NamedNode | null, graph?: any): boolean { if (formFieldSubject && readonly === false) { // if readonly is false, we can ovverride it if the field is marked as uneditable in the form - return !!this.storeContext.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph) + return !!this.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph) } return readonly } @@ -108,14 +109,14 @@ export default class RDFInput extends WebComponent { params?: { defaultInputValue?: string } ) { const defaultTerm = formFieldSubject - ? this.storeContext.store.any(formFieldSubject, ns.ui('default')) + ? this.store.any(formFieldSubject, ns.ui('default')) : undefined if (!uiPropertyTerm || !dataSubject) { return defaultTerm } - const inputTerm = this.storeContext.store.any(dataSubject, uiPropertyTerm) + const inputTerm = this.store.any(dataSubject, uiPropertyTerm) return inputTerm || defaultTerm } @@ -163,23 +164,23 @@ export default class RDFInput extends WebComponent { } const dataDocument = this.getDocument(this.dataSubject) - if (dataDocument && this.storeContext.store.updater?.editable(dataDocument) === false) { + if (dataDocument && this.store.updater?.editable(dataDocument) === false) { this._updateInFlight = false return } - const toDeleteSt = this.storeContext.store.statementsMatching(this.dataSubject, uiPropertyTerm) + const toDeleteSt = this.store.statementsMatching(this.dataSubject, uiPropertyTerm) let toInsertSt: Array> = [] if (newValue) { let objectFromNewValue - const fieldType = this.formSubject ? mostSpecificClassURI(this.storeContext.store, this.formSubject) : undefined + const fieldType = this.formSubject ? mostSpecificClassURI(this.store, this.formSubject) : undefined const params: FieldParamsObject = fieldType ? fieldTypeParams[fieldType] ?? {} : {} if (params.namedNode) { - objectFromNewValue = this.storeContext.store.sym(newValue) + objectFromNewValue = this.store.sym(newValue) } else if (params.defaultInputValue) { objectFromNewValue = encodeURIComponent(newValue.replace(/ /g, '')) - objectFromNewValue = this.storeContext.store.sym(params.defaultInputValue + objectFromNewValue) + objectFromNewValue = this.store.sym(params.defaultInputValue + objectFromNewValue) } else { if (params.dt) { objectFromNewValue = new Literal( @@ -198,7 +199,7 @@ export default class RDFInput extends WebComponent { } try { - await this.storeContext.store.updater.updateMany(toDeleteSt, toInsertSt as any) + await this.store.updater.updateMany(toDeleteSt, toInsertSt as any) this.storeVersion += 1 } catch (err) { console.error('RDFInput update failed', err) diff --git a/src/components/resource-actions-menu/ResourceActionsMenu.styles.css b/src/components/resource-actions-menu/ResourceActionsMenu.styles.css new file mode 100644 index 000000000..2594e9e97 --- /dev/null +++ b/src/components/resource-actions-menu/ResourceActionsMenu.styles.css @@ -0,0 +1,36 @@ +:host { + solid-ui-menu-item { + --solid-ui-menu-item-font-size: var(--solid-ui-font-size-sm); + --solid-ui-menu-item-gap: 10px; + } + + solid-ui-menu { + --solid-ui-menu-min-width: fit-content; + } + + .ellipsisIcon { + width: 16px; + height: 16px; + } +} + +solid-ui-menu::part(menu) { + max-height: min(18rem, calc(100vh - 2rem)); + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: #c7cbd1 transparent; +} + +solid-ui-menu::part(menu)::-webkit-scrollbar { + width: 6px; +} + +solid-ui-menu::part(menu)::-webkit-scrollbar-track { + background: transparent; +} + +solid-ui-menu::part(menu)::-webkit-scrollbar-thumb { + background: #c7cbd1; + border-radius: 999px; +} \ No newline at end of file diff --git a/src/components/resource-actions-menu/ResourceActionsMenu.ts b/src/components/resource-actions-menu/ResourceActionsMenu.ts new file mode 100644 index 000000000..8f29d20bc --- /dev/null +++ b/src/components/resource-actions-menu/ResourceActionsMenu.ts @@ -0,0 +1,111 @@ +import { customElement, property } from 'lit/decorators.js' +import { html, nothing } from 'lit' +import { WebComponent } from '@/lib/components' +import 'solid-ui/components/button' +import 'solid-ui/components/menu' +import 'solid-ui/components/menu-item' +import '~icons/lucide/ellipsis-vertical' +import '~icons/lucide/share-2' +import '~icons/lucide/pencil' +import '~icons/lucide/trash-2' +import '~icons/lucide/globe' +import '~icons/lucide/lock-keyhole' + +import styles from './ResourceActionsMenu.styles.css' +import { getVisibleResourceActions, type ResourceActionMenuItem } from './helpers' + +@customElement('solid-ui-resource-actions-menu') +export default class ResourceActionsMenu extends WebComponent { + static styles = styles + + @property({ type: Boolean }) + accessor isContainerResource = false + + @property({ type: Boolean }) + accessor isMobile = false + + @property({ type: Boolean }) + accessor paneSupportsEditing = false + + @property({ type: Boolean }) + accessor canEdit = false + + @property({ attribute: false }) + accessor handleAccessClick: (() => void) | undefined = undefined + + @property({ attribute: false }) + accessor handleEditingClick: (() => void) | undefined = undefined + + @property({ attribute: false }) + accessor handleDeleteClick: (() => void) | undefined = undefined + + @property({ type: Boolean }) + accessor discoverPublicly = false + + @property({ type: Boolean }) + accessor discoverPrivately = false + + @property({ attribute: false }) + accessor handleDiscoverPublicClick: (() => void) | undefined = undefined + + @property({ attribute: false }) + accessor handleDiscoverPrivateClick: (() => void) | undefined = undefined + + @property({ attribute: false }) + accessor deleteLabel: string | undefined = undefined + + @property({ attribute: false }) + accessor menuItems: ResourceActionMenuItem[] = [] + + render () { + const visibleItems = getVisibleResourceActions({ + isContainerResource: this.isContainerResource, + isMobile: this.isMobile, + paneSupportsEditing: this.paneSupportsEditing, + canEdit: this.canEdit, + deleteLabel: this.deleteLabel, + handleAccessClick: this.handleAccessClick, + handleEditingClick: this.handleEditingClick, + handleDeleteClick: this.handleDeleteClick, + discoverPublicly: this.discoverPublicly, + discoverPrivately: this.discoverPrivately, + handleDiscoverPublicClick: this.handleDiscoverPublicClick, + handleDiscoverPrivateClick: this.handleDiscoverPrivateClick, + menuItems: this.menuItems, + }) + + const renderIcon = (item: ResourceActionMenuItem) => { + if (item.icon) { + return item.icon + } + + if (item.kind === 'edit') { + return html`` + } + + if (item.kind === 'access') { + return html`` + } + + if (item.kind === 'delete') { + return html`` + } + + return nothing + } + + return html` + + + + + ${visibleItems.map(item => html` + + ${renderIcon(item)} + ${item.label} + + `)} + + ` + } +} diff --git a/src/components/resource-actions-menu/helpers.ts b/src/components/resource-actions-menu/helpers.ts new file mode 100644 index 000000000..45d560518 --- /dev/null +++ b/src/components/resource-actions-menu/helpers.ts @@ -0,0 +1,75 @@ +export type ResourceActionMenuItem = { + kind?: 'custom' | 'edit' | 'access' | 'delete' + label: string + icon?: unknown + action: (event: Event) => void +} + +export type ResourceActionsMenuOptions = { + isContainerResource: boolean + isMobile: boolean + paneSupportsEditing: boolean + canEdit: boolean + deleteLabel?: string + handleAccessClick?: (() => void) | undefined + handleEditingClick?: (() => void) | undefined + handleDeleteClick?: (() => void) | undefined + discoverPublicly?: boolean + discoverPrivately?: boolean + handleDiscoverPublicClick?: (() => void) | undefined + handleDiscoverPrivateClick?: (() => void) | undefined + menuItems?: ResourceActionMenuItem[] +} + +export function getVisibleResourceActions (options: ResourceActionsMenuOptions): ResourceActionMenuItem[] { + const visibleItems = [...(options.menuItems ?? [])] + const createDiscoveryIcon = (iconName: string) => { + const icon = document.createElement(iconName) + icon.setAttribute('slot', 'left-icon') + return icon + } + + const canEdit = !options.isContainerResource && options.isMobile && options.paneSupportsEditing && options.canEdit && !!options.handleEditingClick + if (canEdit && options.handleEditingClick) { + visibleItems.push({ + kind: 'edit', + label: 'Edit', + action: options.handleEditingClick, + }) + } + + visibleItems.push({ + kind: 'custom', + label: options.discoverPublicly ? 'Undiscover Publicly' : 'Discover Publicly', + icon: createDiscoveryIcon('icon-lucide-globe'), + action: options.handleDiscoverPublicClick ?? (() => { + }), + }) + + visibleItems.push({ + kind: 'custom', + label: options.discoverPrivately ? 'Undiscover Privately' : 'Discover Privately', + icon: createDiscoveryIcon('icon-lucide-lock-keyhole'), + action: options.handleDiscoverPrivateClick ?? (() => { + }), + }) + + const canManageAccess = !!options.handleAccessClick + if (canManageAccess && options.handleAccessClick) { + visibleItems.push({ + kind: 'access', + label: 'Manage Access', + action: options.handleAccessClick, + }) + } + + if (options.handleDeleteClick) { + visibleItems.push({ + kind: 'delete', + label: options.deleteLabel ?? 'Move to Trash', + action: options.handleDeleteClick, + }) + } + + return visibleItems +} \ No newline at end of file diff --git a/src/components/resource-actions-menu/index.ts b/src/components/resource-actions-menu/index.ts new file mode 100644 index 000000000..33d237faa --- /dev/null +++ b/src/components/resource-actions-menu/index.ts @@ -0,0 +1,8 @@ +import ResourceActionsMenu from './ResourceActionsMenu' +export { getVisibleResourceActions } from './helpers' +export type { ResourceActionMenuItem } from './helpers' +export { buildResourceActionsMenuBindings, getResourceDeleteLabel } from '../../lib/resource-actions-menu' +export { loadDiscoveryState, toggleDiscoveryState } from '../../lib/discovery' + +export { ResourceActionsMenu } +export default ResourceActionsMenu \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 10b6aac46..07215ecae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,10 @@ export type { CreateContext, NewAppInstanceOptions } from './create/types' export * from './lib/auth' export * from './lib/components' +export * from './lib/discovery' export * from './lib/dialogs' export * from './lib/file-explorer/context' +export * from './lib/file-explorer/relevant-panes' export * from './lib/code-editor' +export * from './lib/resource-actions-menu' +export * from './lib/store' diff --git a/src/lib/discovery/discovery.ts b/src/lib/discovery/discovery.ts new file mode 100644 index 000000000..b1da3463e --- /dev/null +++ b/src/lib/discovery/discovery.ts @@ -0,0 +1,45 @@ +import type { NamedNode } from 'rdflib' +import { solidLogicSingleton } from 'solid-logic' +import ns from '../ns' + +export const DEFAULT_DISCOVER_CLASS = ns.owl('Thing') + +export type DiscoveryVisibility = 'public' | 'private' + +export type DiscoveryState = { + public: boolean + private: boolean +} + +export async function loadDiscoveryState (subject: NamedNode): Promise { + const [publicRegistrations, privateRegistrations] = await Promise.all([ + solidLogicSingleton.resource.findTypeIndexRegistrations(subject, 'public'), + solidLogicSingleton.resource.findTypeIndexRegistrations(subject, 'private') + ]) + + return { + public: publicRegistrations.length > 0, + private: privateRegistrations.length > 0 + } +} + +export async function toggleDiscoveryState ( + subject: NamedNode, + discoverClass: NamedNode, + visibility: DiscoveryVisibility, + currentState?: DiscoveryState +): Promise { + const discoveryState = currentState ?? await loadDiscoveryState(subject) + const isDiscovered = visibility === 'public' ? discoveryState.public : discoveryState.private + const resolvedDiscoverClass = solidLogicSingleton.resource.isContainer(subject) + ? await solidLogicSingleton.resource.loadContainerMintClass(subject) ?? discoverClass + : discoverClass + + if (isDiscovered) { + await solidLogicSingleton.resource.removeFromTypeIndex(subject, visibility) + } else { + await solidLogicSingleton.resource.addToTypeIndex(subject, visibility, resolvedDiscoverClass) + } + + return loadDiscoveryState(subject) +} \ No newline at end of file diff --git a/src/lib/discovery/index.ts b/src/lib/discovery/index.ts new file mode 100644 index 000000000..9d03c4940 --- /dev/null +++ b/src/lib/discovery/index.ts @@ -0,0 +1 @@ +export * from './discovery' \ No newline at end of file diff --git a/src/lib/file-explorer/context.ts b/src/lib/file-explorer/context.ts index 9d291174f..216999931 100644 --- a/src/lib/file-explorer/context.ts +++ b/src/lib/file-explorer/context.ts @@ -1,5 +1,4 @@ import { createContext } from '@lit/context' -import { LiveStore } from 'rdflib' import type { NamedNode } from 'rdflib' import type { PaneDefinition } from 'pane-registry' @@ -10,7 +9,6 @@ export interface FileExplorerEdit { } export interface FileExplorerContext { - store: LiveStore | undefined subjectUri: string | undefined pane?: PaneDefinition soloPane?: boolean @@ -18,6 +16,9 @@ export interface FileExplorerContext { onBack?: () => void openPane?: (subject: NamedNode, paneName: string) => void handleAccessClick?: () => void + deleteResource?: (subject: NamedNode) => Promise + resourceRevision?: number + discoverClass?: NamedNode paneSupportsEditing?: boolean edit?: FileExplorerEdit diff --git a/src/lib/file-explorer/relevant-panes.ts b/src/lib/file-explorer/relevant-panes.ts new file mode 100644 index 000000000..0dd7bdaea --- /dev/null +++ b/src/lib/file-explorer/relevant-panes.ts @@ -0,0 +1,42 @@ +import type { DataBrowserContext, PaneDefinition } from 'pane-registry' +import { filterAvailablePanes } from '../../login/login' +import type { NamedNode } from 'rdflib' + +export async function getRelevantPanes ( + subject: NamedNode, + context: DataBrowserContext, + panes: Array = context.session.paneRegistry.list +): Promise> { + const relevantPaneCandidates = panes.filter( + (pane) => pane.label(subject, context) && !pane.global && pane.name !== 'sharing' + ) + + if (relevantPaneCandidates.length === 0) { + const internalPane = context.session.paneRegistry.byName('internal') + return internalPane ? [internalPane] : [] + } + + const filteredPanes = await filterAvailablePanes(relevantPaneCandidates) + + if (filteredPanes.length === 0) { + return relevantPaneCandidates.length > 0 ? [relevantPaneCandidates[0]] : [] + } + + if (relevantPaneCandidates.length === 0) { + return filteredPanes + } + + const firstRelevantPaneIndex = panes.indexOf(relevantPaneCandidates[0]) + const firstFilteredPaneIndex = panes.indexOf(filteredPanes[0]) + + return firstRelevantPaneIndex < firstFilteredPaneIndex + ? [relevantPaneCandidates[0]].concat(filteredPanes) + : filteredPanes +} + +export function getRelevantPane ( + relevantPanes: Array, + subject: NamedNode +): PaneDefinition | undefined { + return relevantPanes.find((pane) => pane.shouldGetFocus?.(subject)) || relevantPanes[0] +} diff --git a/src/lib/forms/store/NoopStore.ts b/src/lib/forms/store/NoopStore.ts deleted file mode 100644 index fac3b041d..000000000 --- a/src/lib/forms/store/NoopStore.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { LiveStore } from 'rdflib' -import { StoreContext } from './StoreContext' - -export default class NoopStore implements StoreContext { - get store (): LiveStore { - throw new Error('Cannot use RDF forms without a store') - } -} diff --git a/src/lib/forms/store/StoreContext.ts b/src/lib/forms/store/StoreContext.ts deleted file mode 100644 index 8cd36d6be..000000000 --- a/src/lib/forms/store/StoreContext.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createContext } from '@lit/context' -import { LiveStore } from 'rdflib' -import NoopStore from './NoopStore' - -export interface StoreContext { - store: LiveStore -} - -export const DEFAULT_STORE = new NoopStore() -export const storeContext = createContext(Symbol('storeContext')) diff --git a/src/lib/resource-actions-menu.ts b/src/lib/resource-actions-menu.ts new file mode 100644 index 000000000..834f147a8 --- /dev/null +++ b/src/lib/resource-actions-menu.ts @@ -0,0 +1,36 @@ +import type { NamedNode } from 'rdflib' +import type { DiscoveryState } from '@/lib/discovery' + +export type ResourceActionsMenuBindings = { + deleteLabel: string + handleAccessClick?: (() => void) | undefined + handleDeleteClick?: (() => void) | undefined + discoverPublicly: boolean + discoverPrivately: boolean + handleDiscoverPublicClick?: (() => void) | undefined + handleDiscoverPrivateClick?: (() => void) | undefined +} + +export function getResourceDeleteLabel (subject?: NamedNode): string { + return subject?.dir()?.uri.endsWith('/Trash/') ? 'Permanently Delete' : 'Move to Trash' +} + +export function buildResourceActionsMenuBindings (options: { + subject?: NamedNode + discoveryState?: DiscoveryState + handleAccessClick?: (() => void) | undefined + canDelete?: boolean + handleDeleteClick?: (() => void) | undefined + handleDiscoverPublicClick?: (() => void) | undefined + handleDiscoverPrivateClick?: (() => void) | undefined +}): ResourceActionsMenuBindings { + return { + deleteLabel: getResourceDeleteLabel(options.subject), + handleAccessClick: options.handleAccessClick, + handleDeleteClick: options.canDelete ? options.handleDeleteClick : undefined, + discoverPublicly: options.discoveryState?.public ?? false, + discoverPrivately: options.discoveryState?.private ?? false, + handleDiscoverPublicClick: options.handleDiscoverPublicClick, + handleDiscoverPrivateClick: options.handleDiscoverPrivateClick, + } +} diff --git a/src/lib/store/NoopStore.ts b/src/lib/store/NoopStore.ts new file mode 100644 index 000000000..cf4600076 --- /dev/null +++ b/src/lib/store/NoopStore.ts @@ -0,0 +1,19 @@ +import { LiveStore } from 'rdflib' + +const throwNoopStoreError = () => { + throw new Error('Cannot use RDF forms without a store') +} + +const NoopStore = new Proxy({} as LiveStore, { + get: throwNoopStoreError, + set: throwNoopStoreError, + has: throwNoopStoreError, + ownKeys: throwNoopStoreError, + getOwnPropertyDescriptor: throwNoopStoreError, + defineProperty: throwNoopStoreError, + deleteProperty: throwNoopStoreError, + apply: throwNoopStoreError, + construct: throwNoopStoreError +}) as LiveStore + +export default NoopStore diff --git a/src/lib/store/context.ts b/src/lib/store/context.ts new file mode 100644 index 000000000..b0e94c631 --- /dev/null +++ b/src/lib/store/context.ts @@ -0,0 +1,8 @@ +import { createContext } from '@lit/context' +import { LiveStore } from 'rdflib' +import NoopStore from './NoopStore' + +export type StoreContext = LiveStore + +export const DEFAULT_STORE: LiveStore = NoopStore +export const storeContext = createContext(Symbol('storeContext')) diff --git a/src/lib/store/index.ts b/src/lib/store/index.ts new file mode 100644 index 000000000..31df1f108 --- /dev/null +++ b/src/lib/store/index.ts @@ -0,0 +1,4 @@ +export * from './context' +export * from './NoopStore' + +export { default as NoopStore } from './NoopStore' diff --git a/src/storybook/components/StorybookProvider.ts b/src/storybook/components/StorybookProvider.ts index 22f35835c..5e221905e 100644 --- a/src/storybook/components/StorybookProvider.ts +++ b/src/storybook/components/StorybookProvider.ts @@ -6,7 +6,8 @@ import StorybookAuth from '../auth/StorybookAuth' import { Account, authContext } from '@/lib/auth' import '@/components/dialogs-root' -import { storeContext, StoreContext } from '@/lib/forms/store/StoreContext' +import { storeContext } from '@/lib/store' +import type { LiveStore } from 'rdflib' import StorybookStore from '../store/StorybookStore' @customElement('storybook-provider') @@ -24,7 +25,7 @@ export class StorybookProvider extends WebComponent { private accessor auth = new StorybookAuth() @provide({ context: storeContext }) - private accessor store: StoreContext = new StorybookStore() + private accessor store: LiveStore = StorybookStore() willUpdate (changedProperties: PropertyValues) { super.willUpdate(changedProperties) @@ -40,8 +41,9 @@ export class StorybookProvider extends WebComponent { this.auth.account = new Account(this.webId, this.avatarUrl) if (this.store) { - // read `store` so the property is considered used + void this.store } + } protected render () { diff --git a/src/storybook/store/StorybookStore.ts b/src/storybook/store/StorybookStore.ts index 0a71df8af..755cff84f 100644 --- a/src/storybook/store/StorybookStore.ts +++ b/src/storybook/store/StorybookStore.ts @@ -1,9 +1,8 @@ -import { StoreContext } from '@/lib/forms/store/StoreContext' import * as rdf from 'rdflib' import { LiveStore } from 'rdflib' -export default class StorybookStore implements StoreContext { - public store: LiveStore = createStore() +export default function StorybookStore (): LiveStore { + return createStore() } function createStore (): rdf.LiveStore { diff --git a/src/styles/theme.css b/src/styles/theme.css index 7a8fe5a17..c5b05e7fa 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -12,13 +12,16 @@ --solid-ui-color-white: #ffffff; --solid-ui-color-error: #b00020; + --solid-ui-color-gray-50: #f9fafb; --solid-ui-color-gray-100: #f3f4f6; --solid-ui-color-gray-200: #cbd5e1; + --solid-ui-color-gray-300: #d1d5db; --solid-ui-color-gray-400: #99a1af; --solid-ui-color-gray-500: #6a7282; --solid-ui-color-gray-600: #4a5565; --solid-ui-color-gray-700: #364153; --solid-ui-color-gray-800: #101828; + --solid-ui-color-gray-900: #030712; --solid-ui-color-slate-50: #f8fafc; --solid-ui-color-slate-200: #e2e8f0; diff --git a/src/types/custom-elements.d.ts b/src/types/custom-elements.d.ts index 62587eafe..342db0d62 100644 --- a/src/types/custom-elements.d.ts +++ b/src/types/custom-elements.d.ts @@ -9,6 +9,7 @@ import type DialogFooter from '../components/dialog-footer/DialogFooter' import type DialogHeader from '../components/dialog-header/DialogHeader' import type DialogProvider from '../components/dialog-provider/DialogProvider' import type DialogsRoot from '../components/dialogs-root/DialogsRoot' +import type FileExplorerHeader from '../components/file-explorer-header/FileExplorerHeader' import type Guard from '../components/guard/Guard' import type Input from '../components/input/Input' import type LoginButton from '../components/login-button/LoginButton' @@ -21,6 +22,7 @@ import type PhotoCaptureModal from '../components/photo-capture-modal/PhotoCaptu import type Provider from '../components/provider/Provider' import type RDFForm from '../components/rdf-form/RDFForm' import type RDFInput from '../components/rdf-input/RDFInput' +import type ResourceActionsMenu from '../components/resource-actions-menu/ResourceActionsMenu' import type Select from '../components/select/Select' import type SelectOption from '../components/select-option/SelectOption' import type SignupButton from '../components/signup-button/SignupButton' @@ -39,6 +41,7 @@ declare global { 'solid-ui-dialog-header': DialogHeader 'solid-ui-dialog-provider': DialogProvider 'solid-ui-dialogs-root': DialogsRoot + 'solid-ui-file-explorer-header': FileExplorerHeader 'solid-ui-guard': Guard 'solid-ui-input': Input 'solid-ui-login-button': LoginButton @@ -51,6 +54,7 @@ declare global { 'solid-ui-provider': Provider 'solid-ui-rdf-form': RDFForm 'solid-ui-rdf-input': RDFInput + 'solid-ui-resource-actions-menu': ResourceActionsMenu 'solid-ui-select': Select 'solid-ui-select-option': SelectOption 'solid-ui-signup-button': SignupButton diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 89a6d9818..964e0a4e7 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -12,19 +12,23 @@ describe('Index', () => { 'CodeEditor', 'DEFAULT_AUTH_CONTEXT', 'DEFAULT_DIALOG_CONTEXT', + 'DEFAULT_DISCOVER_CLASS', 'DEFAULT_SIGNUP_URL', + 'DEFAULT_STORE', 'Dialog', 'DialogComponent', 'DialogTrait', 'FormControlComponent', 'FormControlTrait', 'NoopAuth', + 'NoopStore', 'ShowDialogEvent', 'SolidAuth', 'WebComponent', 'acl', 'aclControl', 'authContext', + 'buildResourceActionsMenuBindings', 'create', 'createTypes', 'customElement', @@ -32,11 +36,15 @@ describe('Index', () => { 'dom', 'fileExplorerContext', 'generateId', + 'getRelevantPane', + 'getRelevantPanes', + 'getResourceDeleteLabel', 'icons', 'infiniteMessageArea', 'initFooter', 'initHeader', 'language', + 'loadDiscoveryState', 'log', 'login', 'matrix', @@ -47,9 +55,11 @@ describe('Index', () => { 'participation', 'preferences', 'showDialog', + 'storeContext', 'style', 'table', 'tabs', + 'toggleDiscoveryState', 'utils', 'widgets' ])