New project + Edit project working
* Can fill out metadata * Uploads SVGs for logos * Editor works and persists/loads data
This commit is contained in:
parent
80d54aaaf0
commit
4fde0e6148
34 changed files with 3910 additions and 1669 deletions
284
package-lock.json
generated
284
package-lock.json
generated
|
|
@ -69,7 +69,7 @@
|
|||
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/node": "^22.0.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
|
|
@ -108,7 +108,6 @@
|
|||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
|
|
@ -134,7 +133,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
|
|
@ -150,7 +148,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
|
|
@ -166,7 +163,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
|
|
@ -182,7 +178,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
|
|
@ -198,7 +193,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
|
|
@ -214,7 +208,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
|
|
@ -230,7 +223,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
|
|
@ -246,7 +238,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
|
|
@ -262,7 +253,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -278,7 +268,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -294,7 +283,6 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -310,7 +298,6 @@
|
|||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -326,7 +313,6 @@
|
|||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -342,7 +328,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -358,7 +343,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -374,7 +358,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -390,7 +373,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
|
|
@ -423,7 +405,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
|
|
@ -456,7 +437,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
|
|
@ -472,7 +452,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
|
|
@ -488,7 +467,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
|
@ -504,7 +482,6 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
|
@ -520,7 +497,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
|
@ -1105,7 +1081,6 @@
|
|||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
|
||||
"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/set-array": "^1.2.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||
|
|
@ -1119,7 +1094,6 @@
|
|||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
|
|
@ -1128,7 +1102,6 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
||||
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
|
|
@ -1143,7 +1116,6 @@
|
|||
"version": "0.3.25",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
||||
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
|
|
@ -1205,8 +1177,7 @@
|
|||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.25",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz",
|
||||
"integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ=="
|
||||
},
|
||||
"node_modules/@poppanator/sveltekit-svg": {
|
||||
"version": "5.0.0-svelte5.4",
|
||||
|
|
@ -1702,6 +1673,15 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@sveltejs/acorn-typescript": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
|
||||
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/adapter-auto": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.2.2.tgz",
|
||||
|
|
@ -1732,7 +1712,6 @@
|
|||
"version": "2.5.18",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.18.tgz",
|
||||
"integrity": "sha512-+g06hvpVAnH7b4CDjhnTDgFWBKBiQJpuSmQeGYOuzbO3SC3tdYjRNlDCrafvDtKbGiT2uxY5Dn9qdEUGVZdWOQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@types/cookie": "^0.6.0",
|
||||
|
|
@ -1761,59 +1740,43 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@sveltejs/vite-plugin-svelte": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",
|
||||
"integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",
|
||||
"dev": true,
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz",
|
||||
"integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0",
|
||||
"debug": "^4.3.7",
|
||||
"deepmerge": "^4.3.1",
|
||||
"kleur": "^4.1.5",
|
||||
"magic-string": "^0.30.10",
|
||||
"svelte-hmr": "^0.16.0",
|
||||
"vitefu": "^0.2.5"
|
||||
"magic-string": "^0.30.12",
|
||||
"vitefu": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20"
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.0",
|
||||
"svelte": "^5.0.0-next.96 || ^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
|
||||
"integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
|
||||
"dev": true,
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz",
|
||||
"integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
"debug": "^4.3.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20"
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0",
|
||||
"svelte": "^5.0.0-next.96 || ^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/vite-plugin-svelte/node_modules/svelte-hmr": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",
|
||||
"integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^12.20 || ^14.13.1 || >= 16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.19.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.12.0.tgz",
|
||||
|
|
@ -2455,8 +2418,7 @@
|
|||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "8.56.10",
|
||||
|
|
@ -2847,10 +2809,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
|
||||
"integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==",
|
||||
"dev": true,
|
||||
"version": "8.14.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -2867,15 +2829,6 @@
|
|||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-typescript": {
|
||||
"version": "1.4.13",
|
||||
"resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz",
|
||||
"integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"acorn": ">=8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
|
|
@ -2917,7 +2870,7 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
|
|
@ -2938,12 +2891,12 @@
|
|||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/array-union": {
|
||||
|
|
@ -3032,12 +2985,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz",
|
||||
"integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
|
|
@ -3058,7 +3011,7 @@
|
|||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
|
|
@ -3086,7 +3039,7 @@
|
|||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
|
|
@ -3218,7 +3171,7 @@
|
|||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
|
|
@ -3242,7 +3195,7 @@
|
|||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
|
|
@ -3263,6 +3216,15 @@
|
|||
"node": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
|
|
@ -3362,7 +3324,6 @@
|
|||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
|
|
@ -3576,8 +3537,7 @@
|
|||
"node_modules/devalue": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.0.0.tgz",
|
||||
"integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA=="
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
"version": "1.1.0",
|
||||
|
|
@ -3718,7 +3678,6 @@
|
|||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
|
|
@ -3913,10 +3872,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/esm-env": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz",
|
||||
"integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==",
|
||||
"dev": true
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.1.0",
|
||||
|
|
@ -3961,13 +3920,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/esrap": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.2.2.tgz",
|
||||
"integrity": "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==",
|
||||
"dev": true,
|
||||
"version": "1.4.6",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz",
|
||||
"integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
"@types/estree": "^1.0.1"
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
}
|
||||
},
|
||||
"node_modules/esrecurse": {
|
||||
|
|
@ -4130,7 +4088,7 @@
|
|||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
|
|
@ -4351,8 +4309,7 @@
|
|||
"node_modules/globalyzer": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
|
||||
"integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="
|
||||
},
|
||||
"node_modules/globby": {
|
||||
"version": "11.1.0",
|
||||
|
|
@ -4377,8 +4334,7 @@
|
|||
"node_modules/globrex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
|
|
@ -4509,7 +4465,7 @@
|
|||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz",
|
||||
"integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.0",
|
||||
|
|
@ -4531,7 +4487,6 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
||||
"integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
|
|
@ -4596,7 +4551,7 @@
|
|||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
|
|
@ -4645,7 +4600,7 @@
|
|||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -4662,7 +4617,7 @@
|
|||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
|
|
@ -4679,7 +4634,7 @@
|
|||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
|
|
@ -4694,14 +4649,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
|
||||
"integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==",
|
||||
"dev": true,
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "*"
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/is-reference/node_modules/@types/estree": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-typedarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
|
||||
|
|
@ -4887,7 +4848,6 @@
|
|||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
|
|
@ -4938,8 +4898,7 @@
|
|||
"node_modules/locate-character": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
|
|
@ -5168,7 +5127,6 @@
|
|||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
|
|
@ -5177,7 +5135,6 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
|
||||
"integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
|
|
@ -5210,7 +5167,6 @@
|
|||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -5286,7 +5242,7 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -5497,7 +5453,6 @@
|
|||
"version": "8.4.39",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
|
||||
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -5967,7 +5922,7 @@
|
|||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
|
|
@ -6170,7 +6125,6 @@
|
|||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"mri": "^1.1.0"
|
||||
},
|
||||
|
|
@ -6220,7 +6174,7 @@
|
|||
"version": "1.77.8",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz",
|
||||
"integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"chokidar": ">=3.0.0 <4.0.0",
|
||||
"immutable": "^4.0.0",
|
||||
|
|
@ -6261,8 +6215,7 @@
|
|||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
|
||||
"integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ=="
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.2",
|
||||
|
|
@ -6348,7 +6301,6 @@
|
|||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
|
||||
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@polka/url": "^1.0.0-next.24",
|
||||
"mrmime": "^2.0.0",
|
||||
|
|
@ -6609,23 +6561,24 @@
|
|||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.0.0-next.169",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.0.0-next.169.tgz",
|
||||
"integrity": "sha512-8VD4/adoVW5Oo4Kub+jnWnuexAtskjbLuAhy3IGMkSKcQ6RVKEQPo4j+8Xr58epow6ccA7S5zp+PsiTv4eW5Zg==",
|
||||
"dev": true,
|
||||
"version": "5.33.6",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.33.6.tgz",
|
||||
"integrity": "sha512-bxg2QY03JlrilCZmDlshY95Argj0rnX43UQFWZN4fct8PZTNBBmvfow2A6yOW1+YweDjhC2qdZF66ASI0Y21Tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
"@types/estree": "^1.0.5",
|
||||
"acorn": "^8.11.3",
|
||||
"acorn-typescript": "^1.4.13",
|
||||
"aria-query": "^5.3.0",
|
||||
"axobject-query": "^4.0.0",
|
||||
"esm-env": "^1.0.0",
|
||||
"esrap": "^1.2.2",
|
||||
"is-reference": "^3.0.2",
|
||||
"acorn": "^8.12.1",
|
||||
"aria-query": "^5.3.1",
|
||||
"axobject-query": "^4.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"esm-env": "^1.2.1",
|
||||
"esrap": "^1.4.6",
|
||||
"is-reference": "^3.0.3",
|
||||
"locate-character": "^3.0.0",
|
||||
"magic-string": "^0.30.5",
|
||||
"magic-string": "^0.30.11",
|
||||
"zimmerframe": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -6840,7 +6793,6 @@
|
|||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
||||
"integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"globalyzer": "0.1.0",
|
||||
"globrex": "^0.1.2"
|
||||
|
|
@ -6917,7 +6869,7 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
|
|
@ -6929,7 +6881,6 @@
|
|||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
|
|
@ -7477,7 +7428,7 @@
|
|||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
|
||||
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -7598,7 +7549,6 @@
|
|||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.2.tgz",
|
||||
"integrity": "sha512-6lA7OBHBlXUxiJxbO5aAY2fsHHzDr1q7DvXYnyZycRs2Dz+dXBWuhpWHvmljTRTpQC2uvGmUFFkSHF2vGo90MA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.38",
|
||||
|
|
@ -7650,13 +7600,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
|
||||
"integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
|
||||
"dev": true,
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.6.tgz",
|
||||
"integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"tests/deps/*",
|
||||
"tests/projects/*"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
|
||||
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vite": {
|
||||
|
|
@ -7850,8 +7803,7 @@
|
|||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
|
||||
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
|
||||
"dev": true
|
||||
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.30",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/node": "^22.0.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
|
|
@ -101,5 +101,8 @@
|
|||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"overrides": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "logoUrl" VARCHAR(500);
|
||||
|
|
@ -22,6 +22,7 @@ model Project {
|
|||
role String? @db.VarChar(255)
|
||||
technologies Json? // Array of tech stack
|
||||
featuredImage String? @db.VarChar(500)
|
||||
logoUrl String? @db.VarChar(500)
|
||||
gallery Json? // Array of image URLs
|
||||
externalUrl String? @db.VarChar(500)
|
||||
caseStudyContent Json? // BlockNote JSON format
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ $red-60: #e33d3d;
|
|||
$red-40: #d31919;
|
||||
$red-00: #3d0c0c;
|
||||
|
||||
$salmon-pink: #ffbdb3; // Desaturated salmon pink for hover states
|
||||
|
||||
$bg-color: #e8e8e8;
|
||||
$page-color: #ffffff;
|
||||
$card-color: #f7f7f7;
|
||||
|
|
|
|||
322
src/lib/components/admin/AdminNavBar.svelte
Normal file
322
src/lib/components/admin/AdminNavBar.svelte
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import Avatar from '$lib/components/Avatar.svelte'
|
||||
|
||||
const currentPath = $derived($page.url.pathname)
|
||||
|
||||
interface NavItem {
|
||||
text: string
|
||||
href: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ text: 'Dashboard', href: '/admin', icon: 'dashboard' },
|
||||
{ text: 'Projects', href: '/admin/projects', icon: 'work' },
|
||||
{ text: 'Universe', href: '/admin/posts', icon: 'universe' },
|
||||
{ text: 'Media', href: '/admin/media', icon: 'photos' }
|
||||
]
|
||||
|
||||
// Calculate active index based on current path
|
||||
const activeIndex = $derived(
|
||||
currentPath === '/admin'
|
||||
? 0
|
||||
: currentPath.startsWith('/admin/projects')
|
||||
? 1
|
||||
: currentPath.startsWith('/admin/posts')
|
||||
? 2
|
||||
: currentPath.startsWith('/admin/media')
|
||||
? 3
|
||||
: -1
|
||||
)
|
||||
</script>
|
||||
|
||||
<nav class="admin-nav-bar">
|
||||
<div class="nav-container">
|
||||
<div class="nav-content">
|
||||
<a href="/" class="nav-brand">
|
||||
<div class="brand-logo">
|
||||
<Avatar />
|
||||
</div>
|
||||
<span class="brand-text">Back to jedmund.com</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-links">
|
||||
{#each navItems as item, index}
|
||||
<a href={item.href} class="nav-link" class:active={index === activeIndex}>
|
||||
<svg class="nav-icon" width="20" height="20" viewBox="0 0 20 20">
|
||||
{#if item.icon === 'dashboard'}
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="6"
|
||||
height="6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
x="11"
|
||||
y="3"
|
||||
width="6"
|
||||
height="6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="6"
|
||||
height="6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
x="11"
|
||||
y="11"
|
||||
width="6"
|
||||
height="6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
{:else if item.icon === 'work'}
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="16"
|
||||
height="12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="2"
|
||||
/>
|
||||
<path
|
||||
d="M8 4V3C8 2.44772 8.44772 2 9 2H11C11.5523 2 12 2.44772 12 3V4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<line x1="2" y1="9" x2="18" y2="9" stroke="currentColor" stroke-width="1.5" />
|
||||
{:else if item.icon === 'universe'}
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="10" cy="10" r="2" fill="currentColor" />
|
||||
<circle cx="10" cy="4" r="1" fill="currentColor" />
|
||||
<circle cx="16" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="10" cy="16" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="10" r="1" fill="currentColor" />
|
||||
{:else if item.icon === 'photos'}
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="14"
|
||||
height="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
<circle
|
||||
cx="7"
|
||||
cy="9"
|
||||
r="1.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M3 12L7 8L10 11L13 8L17 12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span class="nav-text">{item.text}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
// Breakpoint variables
|
||||
$phone-max: 639px;
|
||||
$tablet-min: 640px;
|
||||
$tablet-max: 1023px;
|
||||
$laptop-min: 1024px;
|
||||
$laptop-max: 1439px;
|
||||
$monitor-min: 1440px;
|
||||
|
||||
.admin-nav-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
background-color: $grey-60;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
width: 100%;
|
||||
padding: 0 $unit-3x;
|
||||
|
||||
// Phone: Full width with padding
|
||||
@media (max-width: $phone-max) {
|
||||
padding: 0 $unit-2x;
|
||||
}
|
||||
|
||||
// Tablet: Constrained width
|
||||
@media (min-width: $tablet-min) and (max-width: $tablet-max) {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: 0 $unit-4x;
|
||||
}
|
||||
|
||||
// Laptop: Wider constrained width
|
||||
@media (min-width: $laptop-min) and (max-width: $laptop-max) {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 $unit-5x;
|
||||
}
|
||||
|
||||
// Monitor: Maximum constrained width
|
||||
@media (min-width: $monitor-min) {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 $unit-6x;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 64px;
|
||||
gap: $unit-4x;
|
||||
|
||||
@media (max-width: $phone-max) {
|
||||
height: 56px;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
text-decoration: none;
|
||||
color: $grey-30;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 0.925rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(.face-container) {
|
||||
--face-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: $phone-max) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
flex: 1;
|
||||
justify-content: right;
|
||||
|
||||
@media (max-width: $phone-max) {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
padding: $unit $unit-2x;
|
||||
border-radius: $card-corner-radius;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
font-weight: 500;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
color: $grey-30;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
@media (max-width: $phone-max) {
|
||||
padding: $unit-2x $unit;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $red-60;
|
||||
background-color: $salmon-pink;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: white;
|
||||
background-color: $red-60;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
|
||||
@media (max-width: $tablet-max) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
@media (max-width: $phone-max) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
// Placeholder for future actions if needed
|
||||
}
|
||||
</style>
|
||||
91
src/lib/components/admin/AdminPage.svelte
Normal file
91
src/lib/components/admin/AdminPage.svelte
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<script lang="ts">
|
||||
export let noHorizontalPadding = false
|
||||
</script>
|
||||
|
||||
<section class="admin-page" class:no-horizontal-padding={noHorizontalPadding}>
|
||||
<div class="page-header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{#if $$slots.fullwidth}
|
||||
<div class="page-fullwidth">
|
||||
<slot name="fullwidth" />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
@import '$styles/mixins.scss';
|
||||
|
||||
.admin-page {
|
||||
background: white;
|
||||
border-radius: $card-corner-radius;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 auto $unit-6x;
|
||||
width: calc(100% - #{$unit-6x});
|
||||
max-width: 900px; // Much wider for admin
|
||||
overflow: hidden; // Ensure border-radius clips content
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
margin-bottom: $unit-3x;
|
||||
width: calc(100% - #{$unit-4x});
|
||||
}
|
||||
|
||||
@include breakpoint('small-phone') {
|
||||
width: calc(100% - #{$unit-3x});
|
||||
}
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: $unit-4x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
@include breakpoint('small-phone') {
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
||||
:global(header) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 0 $unit-2x $unit-4x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: 0 $unit-3x $unit-3x;
|
||||
}
|
||||
|
||||
@include breakpoint('small-phone') {
|
||||
padding: 0 $unit-2x $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.page-fullwidth {
|
||||
padding: 0;
|
||||
margin-top: $unit-3x;
|
||||
|
||||
@include breakpoint('small-phone') {
|
||||
margin-top: $unit-2x;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
isLoading?: boolean
|
||||
emptyMessage?: string
|
||||
onRowClick?: (item: T) => void
|
||||
unstyled?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -20,7 +21,8 @@
|
|||
columns = [],
|
||||
isLoading = false,
|
||||
emptyMessage = 'No data found',
|
||||
onRowClick
|
||||
onRowClick,
|
||||
unstyled = false
|
||||
}: Props<any> = $props()
|
||||
|
||||
function getCellValue(item: any, column: Column<any>) {
|
||||
|
|
@ -39,7 +41,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper" class:unstyled>
|
||||
{#if isLoading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
|
|
@ -85,6 +87,11 @@
|
|||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&.unstyled {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
|
|
|||
123
src/lib/components/admin/DeleteConfirmationModal.svelte
Normal file
123
src/lib/components/admin/DeleteConfirmationModal.svelte
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
let {
|
||||
title = 'Delete item?',
|
||||
message,
|
||||
confirmText = 'Delete',
|
||||
cancelText = 'Cancel'
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
confirm: void
|
||||
cancel: void
|
||||
}>()
|
||||
|
||||
function handleConfirm() {
|
||||
dispatch('confirm')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
dispatch('cancel')
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
dispatch('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick={handleCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button class="btn btn-danger" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: $unit-2x;
|
||||
padding: $unit-4x;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
|
||||
h2 {
|
||||
margin: 0 0 $unit-2x;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit-4x;
|
||||
color: $grey-20;
|
||||
line-height: 1.5;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&.btn-secondary {
|
||||
background-color: $grey-85;
|
||||
color: $grey-20;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-80;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background-color: $red-60;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: $red-40;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
initialized = true
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const json = props.editor.getJSON()
|
||||
data = json
|
||||
onChange?.(json)
|
||||
|
|
@ -72,9 +72,9 @@
|
|||
|
||||
<div class="editor-wrapper {className}" style="--min-height: {minHeight}px">
|
||||
<div class="editor-container">
|
||||
<EditorWithUpload
|
||||
bind:editor
|
||||
content={data}
|
||||
<EditorWithUpload
|
||||
bind:editor
|
||||
content={data}
|
||||
{onUpdate}
|
||||
editable={!readOnly}
|
||||
{showToolbar}
|
||||
|
|
@ -88,8 +88,8 @@
|
|||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$lib/../assets/styles/variables.scss';
|
||||
@import '$lib/../assets/styles/mixins.scss';
|
||||
@import '$styles/variables.scss';
|
||||
@import '$styles/mixins.scss';
|
||||
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
|
|
@ -117,16 +117,17 @@
|
|||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
:global(.editor-content .edra) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
:global(.editor-content .editor-toolbar) {
|
||||
border-bottom: 1px solid $grey-80;
|
||||
border-radius: $card-corner-radius;
|
||||
box-sizing: border-box;
|
||||
background: $grey-95;
|
||||
padding: $unit-2x;
|
||||
position: sticky;
|
||||
|
|
@ -136,20 +137,23 @@
|
|||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
|
||||
// Hide scrollbar but keep functionality
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
// Override Edra toolbar styles
|
||||
:global(.edra-toolbar) {
|
||||
overflow: visible;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,8 +163,8 @@
|
|||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: $unit-4x;
|
||||
|
||||
padding: 0 $unit-4x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
|
@ -286,7 +290,7 @@
|
|||
color: $grey-50;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
|
||||
// Image styles
|
||||
:global(.edra .ProseMirror img) {
|
||||
max-width: 100%;
|
||||
|
|
@ -298,11 +302,11 @@
|
|||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
|
||||
:global(.edra-media-placeholder-wrapper) {
|
||||
margin: $unit-2x 0;
|
||||
}
|
||||
|
||||
|
||||
:global(.edra-media-placeholder-content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -315,51 +319,51 @@
|
|||
background: $grey-95;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-60;
|
||||
background: $grey-90;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
:global(.edra-media-placeholder-icon) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: $grey-50;
|
||||
}
|
||||
|
||||
|
||||
:global(.edra-media-placeholder-text) {
|
||||
font-size: 1rem;
|
||||
color: $grey-30;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
// Image container styles
|
||||
:global(.edra-media-container) {
|
||||
margin: $unit-3x auto;
|
||||
position: relative;
|
||||
|
||||
|
||||
&.align-left {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
|
||||
&.align-right {
|
||||
margin-right: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
&.align-center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
:global(.edra-media-content) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
:global(.edra-media-caption) {
|
||||
width: 100%;
|
||||
margin-top: $unit;
|
||||
|
|
@ -370,11 +374,11 @@
|
|||
color: $grey-30;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
background: $grey-95;
|
||||
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-60;
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
import { EdraToolbar, EdraBubbleMenu } from '$lib/components/edra/headless/index.js'
|
||||
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
|
||||
import { focusEditor, type EdraProps } from '$lib/components/edra/utils.js'
|
||||
|
||||
import EdraToolBarIcon from '$lib/components/edra/headless/components/EdraToolBarIcon.svelte'
|
||||
import { commands } from '$lib/components/edra/commands/commands.js'
|
||||
|
||||
// Import all the same components as Edra
|
||||
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
|
||||
import { all, createLowlight } from 'lowlight'
|
||||
|
|
@ -32,15 +34,15 @@
|
|||
import { IFramePlaceholder } from '$lib/components/edra/extensions/iframe/IFramePlaceholder.js'
|
||||
import { IFrameExtended } from '$lib/components/edra/extensions/iframe/IFrameExtended.js'
|
||||
import IFrameExtendedComponent from '$lib/components/edra/headless/components/IFrameExtended.svelte'
|
||||
|
||||
|
||||
// Import Edra styles
|
||||
import '$lib/components/edra/headless/style.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import '$lib/components/edra/editor.css'
|
||||
import '$lib/components/edra/onedark.css'
|
||||
|
||||
|
||||
const lowlight = createLowlight(all)
|
||||
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
content = undefined,
|
||||
|
|
@ -54,50 +56,192 @@
|
|||
showToolbar = true,
|
||||
placeholder = 'Type "/" for commands...'
|
||||
}: EdraProps & { showToolbar?: boolean; placeholder?: string } = $props()
|
||||
|
||||
|
||||
let element = $state<HTMLElement>()
|
||||
let isLoading = $state(true)
|
||||
let showTextStyleDropdown = $state(false)
|
||||
let showMediaDropdown = $state(false)
|
||||
let dropdownTriggerRef = $state<HTMLElement>()
|
||||
let mediaDropdownTriggerRef = $state<HTMLElement>()
|
||||
let dropdownPosition = $state({ top: 0, left: 0 })
|
||||
let mediaDropdownPosition = $state({ top: 0, left: 0 })
|
||||
|
||||
// Filter out unwanted commands
|
||||
const getFilteredCommands = () => {
|
||||
const filtered = { ...commands }
|
||||
|
||||
// Remove these groups entirely
|
||||
delete filtered['undo-redo']
|
||||
delete filtered['headings'] // In text style dropdown
|
||||
delete filtered['lists'] // In text style dropdown
|
||||
delete filtered['alignment'] // Not needed
|
||||
delete filtered['table'] // Not needed
|
||||
delete filtered['media'] // Will be in media dropdown
|
||||
|
||||
// Reorganize text-formatting commands
|
||||
if (filtered['text-formatting']) {
|
||||
const allCommands = filtered['text-formatting'].commands
|
||||
const basicFormatting = []
|
||||
const advancedFormatting = []
|
||||
|
||||
// Group basic formatting first
|
||||
const basicOrder = ['bold', 'italic', 'underline', 'strike']
|
||||
basicOrder.forEach(name => {
|
||||
const cmd = allCommands.find(c => c.name === name)
|
||||
if (cmd) basicFormatting.push(cmd)
|
||||
})
|
||||
|
||||
// Then link and code
|
||||
const advancedOrder = ['link', 'code']
|
||||
advancedOrder.forEach(name => {
|
||||
const cmd = allCommands.find(c => c.name === name)
|
||||
if (cmd) advancedFormatting.push(cmd)
|
||||
})
|
||||
|
||||
// Create two groups
|
||||
filtered['basic-formatting'] = {
|
||||
name: 'Basic Formatting',
|
||||
label: 'Basic Formatting',
|
||||
commands: basicFormatting
|
||||
}
|
||||
|
||||
filtered['advanced-formatting'] = {
|
||||
name: 'Advanced Formatting',
|
||||
label: 'Advanced Formatting',
|
||||
commands: advancedFormatting
|
||||
}
|
||||
|
||||
// Remove original text-formatting
|
||||
delete filtered['text-formatting']
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Get media commands, but filter out iframe
|
||||
const getMediaCommands = () => {
|
||||
if (commands.media) {
|
||||
return commands.media.commands.filter(cmd => cmd.name !== 'iframe-placeholder')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const filteredCommands = getFilteredCommands()
|
||||
const colorCommands = commands.colors.commands
|
||||
const fontCommands = commands.fonts.commands
|
||||
const excludedCommands = ['colors', 'fonts']
|
||||
|
||||
// Get current text style for dropdown
|
||||
const getCurrentTextStyle = (editor: Editor) => {
|
||||
if (editor.isActive('heading', { level: 1 })) return 'Heading 1'
|
||||
if (editor.isActive('heading', { level: 2 })) return 'Heading 2'
|
||||
if (editor.isActive('heading', { level: 3 })) return 'Heading 3'
|
||||
if (editor.isActive('bulletList')) return 'Bullet List'
|
||||
if (editor.isActive('orderedList')) return 'Ordered List'
|
||||
if (editor.isActive('taskList')) return 'Task List'
|
||||
if (editor.isActive('codeBlock')) return 'Code Block'
|
||||
if (editor.isActive('blockquote')) return 'Blockquote'
|
||||
return 'Paragraph'
|
||||
}
|
||||
|
||||
// Calculate dropdown position
|
||||
const updateDropdownPosition = () => {
|
||||
if (dropdownTriggerRef) {
|
||||
const rect = dropdownTriggerRef.getBoundingClientRect()
|
||||
dropdownPosition = {
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle dropdown with position update
|
||||
const toggleDropdown = () => {
|
||||
if (!showTextStyleDropdown) {
|
||||
updateDropdownPosition()
|
||||
}
|
||||
showTextStyleDropdown = !showTextStyleDropdown
|
||||
}
|
||||
|
||||
// Update media dropdown position
|
||||
const updateMediaDropdownPosition = () => {
|
||||
if (mediaDropdownTriggerRef) {
|
||||
const rect = mediaDropdownTriggerRef.getBoundingClientRect()
|
||||
mediaDropdownPosition = {
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle media dropdown
|
||||
const toggleMediaDropdown = () => {
|
||||
if (!showMediaDropdown) {
|
||||
updateMediaDropdownPosition()
|
||||
}
|
||||
showMediaDropdown = !showMediaDropdown
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!dropdownTriggerRef?.contains(target) && !target.closest('.dropdown-menu-portal')) {
|
||||
showTextStyleDropdown = false
|
||||
}
|
||||
if (!mediaDropdownTriggerRef?.contains(target) && !target.closest('.media-dropdown-portal')) {
|
||||
showMediaDropdown = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showTextStyleDropdown || showMediaDropdown) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Custom image paste handler
|
||||
function handleImagePaste(view: any, event: ClipboardEvent) {
|
||||
const item = event.clipboardData?.items[0]
|
||||
|
||||
|
||||
if (item?.type.indexOf('image') !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
const file = item.getAsFile()
|
||||
if (!file) return false
|
||||
|
||||
|
||||
// Check file size (2MB max)
|
||||
const filesize = file.size / 1024 / 1024
|
||||
if (filesize > 2) {
|
||||
alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Upload to our media API
|
||||
uploadImage(file)
|
||||
|
||||
|
||||
return true // Prevent default paste behavior
|
||||
}
|
||||
|
||||
|
||||
async function uploadImage(file: File) {
|
||||
if (!editor) return
|
||||
|
||||
|
||||
// Create a placeholder while uploading
|
||||
const placeholderSrc = URL.createObjectURL(file)
|
||||
editor.commands.setImage({ src: placeholderSrc })
|
||||
|
||||
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
|
||||
const response = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -105,17 +249,17 @@
|
|||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed')
|
||||
}
|
||||
|
||||
|
||||
const media = await response.json()
|
||||
|
||||
|
||||
// Replace placeholder with actual URL
|
||||
// Set a reasonable default width (max 600px)
|
||||
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
||||
|
||||
|
||||
editor.commands.insertContent({
|
||||
type: 'image',
|
||||
attrs: {
|
||||
|
|
@ -126,7 +270,7 @@
|
|||
align: 'center'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Clean up the object URL
|
||||
URL.revokeObjectURL(placeholderSrc)
|
||||
} catch (error) {
|
||||
|
|
@ -136,7 +280,7 @@
|
|||
editor.commands.undo()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMount(() => {
|
||||
editor = initiateEditor(
|
||||
element,
|
||||
|
|
@ -175,18 +319,18 @@
|
|||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
// Add placeholder
|
||||
if (placeholder && editor) {
|
||||
editor.extensionManager.extensions.find(
|
||||
ext => ext.name === 'placeholder'
|
||||
)?.configure({
|
||||
placeholder
|
||||
})
|
||||
editor.extensionManager.extensions
|
||||
.find((ext) => ext.name === 'placeholder')
|
||||
?.configure({
|
||||
placeholder
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
isLoading = false
|
||||
|
||||
|
||||
return () => editor?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -194,7 +338,81 @@
|
|||
<div class={`edra ${className}`}>
|
||||
{#if showToolbar && editor && !isLoading}
|
||||
<div class="editor-toolbar">
|
||||
<EdraToolbar {editor} />
|
||||
<div class="edra-toolbar">
|
||||
<!-- Text Style Dropdown -->
|
||||
<div class="text-style-dropdown">
|
||||
<button
|
||||
bind:this={dropdownTriggerRef}
|
||||
class="dropdown-trigger"
|
||||
onclick={toggleDropdown}
|
||||
>
|
||||
<span>{getCurrentTextStyle(editor)}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="separator"></span>
|
||||
|
||||
{#each Object.keys(filteredCommands).filter((key) => !excludedCommands.includes(key)) as keys}
|
||||
{@const groups = filteredCommands[keys].commands}
|
||||
{#each groups as command}
|
||||
<EdraToolBarIcon {command} {editor} />
|
||||
{/each}
|
||||
<span class="separator"></span>
|
||||
{/each}
|
||||
|
||||
<!-- Media Dropdown -->
|
||||
<div class="text-style-dropdown">
|
||||
<button
|
||||
bind:this={mediaDropdownTriggerRef}
|
||||
class="dropdown-trigger"
|
||||
onclick={toggleMediaDropdown}
|
||||
>
|
||||
<span>Insert</span>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="separator"></span>
|
||||
|
||||
<EdraToolBarIcon
|
||||
command={colorCommands[0]}
|
||||
{editor}
|
||||
style={`color: ${editor.getAttributes('textStyle').color};`}
|
||||
onclick={() => {
|
||||
const color = editor.getAttributes('textStyle').color;
|
||||
const hasColor = editor.isActive('textStyle', { color });
|
||||
if (hasColor) {
|
||||
editor.chain().focus().unsetColor().run();
|
||||
} else {
|
||||
const color = prompt('Enter the color of the text:');
|
||||
if (color !== null) {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EdraToolBarIcon
|
||||
command={colorCommands[1]}
|
||||
{editor}
|
||||
style={`background-color: ${editor.getAttributes('highlight').color};`}
|
||||
onclick={() => {
|
||||
const hasHightlight = editor.isActive('highlight');
|
||||
if (hasHightlight) {
|
||||
editor.chain().focus().unsetHighlight().run();
|
||||
} else {
|
||||
const color = prompt('Enter the color of the highlight:');
|
||||
if (color !== null) {
|
||||
editor.chain().focus().setHighlight({ color }).run();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if editor}
|
||||
|
|
@ -225,6 +443,115 @@
|
|||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Media Dropdown Portal -->
|
||||
{#if showMediaDropdown}
|
||||
<div
|
||||
class="media-dropdown-portal"
|
||||
style="position: fixed; top: {mediaDropdownPosition.top}px; left: {mediaDropdownPosition.left}px; z-index: 10000;"
|
||||
>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().insertImagePlaceholder().run()
|
||||
showMediaDropdown = false
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
|
||||
<rect x="3" y="5" width="14" height="10" stroke="currentColor" stroke-width="2" fill="none" rx="1"/>
|
||||
<circle cx="7" cy="9" r="1.5" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||
<path d="M3 12L7 8L10 11L13 8L17 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
<span>Image</span>
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().insertVideoPlaceholder().run()
|
||||
showMediaDropdown = false
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
|
||||
<rect x="3" y="4" width="14" height="12" stroke="currentColor" stroke-width="2" fill="none" rx="2"/>
|
||||
<path d="M8 8.5L12 10L8 11.5V8.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().insertAudioPlaceholder().run()
|
||||
showMediaDropdown = false
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
|
||||
<path d="M10 4L10 16M6 8L6 12M14 8L14 12M2 6L2 14M18 6L18 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Audio</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dropdown Menu Portal -->
|
||||
{#if showTextStyleDropdown}
|
||||
<div
|
||||
class="dropdown-menu-portal"
|
||||
style="position: fixed; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; z-index: 10000;"
|
||||
>
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().setParagraph().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
Paragraph
|
||||
</button>
|
||||
<div class="dropdown-separator"></div>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
Heading 1
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
Heading 2
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
Heading 3
|
||||
</button>
|
||||
<div class="dropdown-separator"></div>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleBulletList().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
Unordered List
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleOrderedList().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
Ordered List
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleTaskList().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
Task List
|
||||
</button>
|
||||
<div class="dropdown-separator"></div>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleCodeBlock().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
Code Block
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => {
|
||||
editor?.chain().focus().toggleBlockquote().run()
|
||||
showTextStyleDropdown = false
|
||||
}}>
|
||||
Blockquote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.edra {
|
||||
width: 100%;
|
||||
|
|
@ -233,10 +560,10 @@
|
|||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid var(--edra-border-color);
|
||||
background: var(--edra-button-bg-color);
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
|
@ -248,14 +575,15 @@
|
|||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.edra-editor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
:global(.ProseMirror) {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
|
|
@ -269,4 +597,109 @@
|
|||
outline: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
/* Text Style Dropdown Styles */
|
||||
.text-style-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--edra-text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 120px;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.dropdown-trigger:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
min-width: 160px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--edra-text-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dropdown-separator {
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* Separator in toolbar */
|
||||
:global(.edra-toolbar .separator) {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 1px;
|
||||
margin: 0 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Remove default button backgrounds */
|
||||
:global(.edra-toolbar button) {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.edra-toolbar button:hover) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:global(.edra-toolbar button.active),
|
||||
:global(.edra-toolbar button[data-active="true"]) {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Thicker strokes for icons */
|
||||
:global(.edra-toolbar svg) {
|
||||
stroke-width: 2;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
153
src/lib/components/admin/PostDropdown.svelte
Normal file
153
src/lib/components/admin/PostDropdown.svelte
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
let isOpen = $state(false)
|
||||
let buttonRef: HTMLButtonElement
|
||||
|
||||
const postTypes = [
|
||||
{ value: 'blog', label: '📝 Blog Post', description: 'Long-form article' },
|
||||
{ value: 'microblog', label: '💭 Microblog', description: 'Short thought' },
|
||||
{ value: 'link', label: '🔗 Link', description: 'Share a link' },
|
||||
{ value: 'photo', label: '📷 Photo', description: 'Single photo post' },
|
||||
{ value: 'album', label: '🖼️ Album', description: 'Photo collection' }
|
||||
]
|
||||
|
||||
function handleSelection(type: string) {
|
||||
isOpen = false
|
||||
goto(`/admin/posts/new?type=${type}`)
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (!buttonRef?.contains(event.target as Node)) {
|
||||
isOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="dropdown-container">
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
class="btn btn-primary"
|
||||
onclick={(e) => { e.stopPropagation(); isOpen = !isOpen }}
|
||||
>
|
||||
New Post
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="chevron">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="dropdown-menu">
|
||||
{#each postTypes as type}
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => handleSelection(type.value)}
|
||||
>
|
||||
<span class="dropdown-icon">{type.label}</span>
|
||||
<div class="dropdown-text">
|
||||
<span class="dropdown-label">{type.label.split(' ')[1]}</span>
|
||||
<span class="dropdown-description">{type.description}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.925rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
|
||||
&.btn-primary {
|
||||
background-color: $grey-10;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + $unit);
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-width: 200px;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
padding: $unit-2x;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
text-align: left;
|
||||
transition: background 0.2s ease;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $grey-90;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdown-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dropdown-label {
|
||||
font-size: 0.925rem;
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.dropdown-description {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-40;
|
||||
}
|
||||
</style>
|
||||
186
src/lib/components/admin/ProjectBrandingForm.svelte
Normal file
186
src/lib/components/admin/ProjectBrandingForm.svelte
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<script lang="ts">
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import type { ProjectFormData } from '$lib/types/project'
|
||||
|
||||
interface Props {
|
||||
formData: ProjectFormData
|
||||
logoUploadInProgress: boolean
|
||||
onLogoUpload: (event: Event) => void
|
||||
onRemoveLogo: () => void
|
||||
}
|
||||
|
||||
let { formData = $bindable(), logoUploadInProgress, onLogoUpload, onRemoveLogo }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Branding</h2>
|
||||
|
||||
<FormFieldWrapper
|
||||
label="Logo"
|
||||
helpText="SVG logo for project thumbnail (max 500KB)"
|
||||
>
|
||||
<div class="logo-upload-wrapper">
|
||||
{#if formData.logoUrl}
|
||||
<div class="logo-preview">
|
||||
<img src={formData.logoUrl} alt="Project logo" />
|
||||
<button
|
||||
type="button"
|
||||
class="remove-logo"
|
||||
onclick={onRemoveLogo}
|
||||
aria-label="Remove logo"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="logo-upload-placeholder">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/svg+xml"
|
||||
onchange={onLogoUpload}
|
||||
disabled={logoUploadInProgress}
|
||||
/>
|
||||
{#if logoUploadInProgress}
|
||||
<div class="upload-loading">Uploading...</div>
|
||||
{:else}
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<rect x="8" y="8" width="24" height="24" stroke="currentColor" stroke-width="2" stroke-dasharray="4 4" rx="4"/>
|
||||
<path d="M20 16V24M16 20H24" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Upload SVG Logo</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.form-section {
|
||||
margin-bottom: $unit-6x;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-upload-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.logo-preview {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: $grey-95;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $unit-2x;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
max-width: 80%;
|
||||
max-height: 80%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.remove-logo {
|
||||
position: absolute;
|
||||
top: $unit;
|
||||
right: $unit;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: white;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
border-color: $grey-60;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .remove-logo {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $unit;
|
||||
width: 200px;
|
||||
height: 120px;
|
||||
background: $grey-97;
|
||||
border: 2px dashed $grey-80;
|
||||
border-radius: $unit-2x;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
input[type="file"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: $grey-50;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
border-color: $grey-60;
|
||||
|
||||
svg {
|
||||
color: $grey-40;
|
||||
}
|
||||
|
||||
span {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-loading {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
601
src/lib/components/admin/ProjectForm.svelte
Normal file
601
src/lib/components/admin/ProjectForm.svelte
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { z } from 'zod'
|
||||
import AdminPage from './AdminPage.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
||||
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
||||
import ProjectStylingForm from './ProjectStylingForm.svelte'
|
||||
import { projectSchema } from '$lib/schemas/project'
|
||||
import type { Project, ProjectFormData } from '$lib/types/project'
|
||||
import { defaultProjectFormData } from '$lib/types/project'
|
||||
|
||||
interface Props {
|
||||
project?: Project | null
|
||||
mode: 'create' | 'edit'
|
||||
}
|
||||
|
||||
let { project = null, mode }: Props = $props()
|
||||
|
||||
// State
|
||||
let isLoading = $state(mode === 'edit')
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
let successMessage = $state('')
|
||||
let activeTab = $state('metadata')
|
||||
let validationErrors = $state<Record<string, string>>({})
|
||||
let showPublishMenu = $state(false)
|
||||
|
||||
// Form data
|
||||
let formData = $state<ProjectFormData>({ ...defaultProjectFormData })
|
||||
let logoUploadInProgress = $state(false)
|
||||
|
||||
// Ref to the editor component
|
||||
let editorRef: any
|
||||
|
||||
const tabOptions = [
|
||||
{ value: 'metadata', label: 'Metadata' },
|
||||
{ value: 'case-study', label: 'Case Study' }
|
||||
]
|
||||
|
||||
onMount(() => {
|
||||
if (project) {
|
||||
populateFormData(project)
|
||||
}
|
||||
if (mode === 'create') {
|
||||
isLoading = false
|
||||
}
|
||||
})
|
||||
|
||||
function populateFormData(data: Project) {
|
||||
formData = {
|
||||
title: data.title || '',
|
||||
subtitle: data.subtitle || '',
|
||||
description: data.description || '',
|
||||
year: data.year || new Date().getFullYear(),
|
||||
client: data.client || '',
|
||||
role: data.role || '',
|
||||
technologies: Array.isArray(data.technologies) ? data.technologies.join(', ') : '',
|
||||
externalUrl: data.externalUrl || '',
|
||||
backgroundColor: data.backgroundColor || '',
|
||||
highlightColor: data.highlightColor || '',
|
||||
logoUrl: data.logoUrl || '',
|
||||
status: (data.status as 'draft' | 'published') || 'draft',
|
||||
caseStudyContent: data.caseStudyContent || {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }]
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
try {
|
||||
projectSchema.parse({
|
||||
title: formData.title,
|
||||
description: formData.description || undefined,
|
||||
year: formData.year,
|
||||
client: formData.client || undefined,
|
||||
externalUrl: formData.externalUrl || undefined,
|
||||
backgroundColor: formData.backgroundColor || undefined,
|
||||
highlightColor: formData.highlightColor || undefined,
|
||||
status: formData.status
|
||||
})
|
||||
validationErrors = {}
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const errors: Record<string, string> = {}
|
||||
err.errors.forEach((e) => {
|
||||
if (e.path[0]) {
|
||||
errors[e.path[0].toString()] = e.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditorChange(content: any) {
|
||||
formData.caseStudyContent = content
|
||||
}
|
||||
|
||||
async function handleLogoUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
|
||||
if (!file) return
|
||||
|
||||
// Check if it's an SVG
|
||||
if (file.type !== 'image/svg+xml') {
|
||||
error = 'Please upload an SVG file'
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size (500KB max for SVG)
|
||||
const filesize = file.size / 1024 / 1024
|
||||
if (filesize > 0.5) {
|
||||
error = `Logo file too large! File size: ${filesize.toFixed(2)} MB (max 0.5MB)`
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logoUploadInProgress = true
|
||||
error = ''
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
const uploadFormData = new FormData()
|
||||
uploadFormData.append('file', file)
|
||||
|
||||
const response = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`
|
||||
},
|
||||
body: uploadFormData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed')
|
||||
}
|
||||
|
||||
const media = await response.json()
|
||||
formData.logoUrl = media.url
|
||||
successMessage = 'Logo uploaded successfully!'
|
||||
|
||||
setTimeout(() => {
|
||||
successMessage = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error = 'Failed to upload logo'
|
||||
console.error(err)
|
||||
} finally {
|
||||
logoUploadInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeLogo() {
|
||||
formData.logoUrl = ''
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// Check if we're on the case study tab and should save editor content
|
||||
if (activeTab === 'case-study' && editorRef) {
|
||||
const editorData = await editorRef.save()
|
||||
if (editorData) {
|
||||
formData.caseStudyContent = editorData
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateForm()) {
|
||||
error = 'Please fix the validation errors'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSaving = true
|
||||
error = ''
|
||||
successMessage = ''
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: formData.title,
|
||||
subtitle: formData.subtitle,
|
||||
description: formData.description,
|
||||
year: formData.year,
|
||||
client: formData.client,
|
||||
role: formData.role,
|
||||
technologies: formData.technologies
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
externalUrl: formData.externalUrl,
|
||||
logoUrl: formData.logoUrl,
|
||||
backgroundColor: formData.backgroundColor,
|
||||
highlightColor: formData.highlightColor,
|
||||
status: formData.status,
|
||||
caseStudyContent:
|
||||
formData.caseStudyContent && formData.caseStudyContent.content && formData.caseStudyContent.content.length > 0
|
||||
? formData.caseStudyContent
|
||||
: null
|
||||
}
|
||||
|
||||
const url = mode === 'edit' ? `/api/projects/${project?.id}` : '/api/projects'
|
||||
const method = mode === 'edit' ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`)
|
||||
}
|
||||
|
||||
const savedProject = await response.json()
|
||||
successMessage = `Project ${mode === 'edit' ? 'saved' : 'created'} successfully!`
|
||||
|
||||
setTimeout(() => {
|
||||
successMessage = ''
|
||||
if (mode === 'create') {
|
||||
goto(`/admin/projects/${savedProject.id}/edit`)
|
||||
}
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
error = `Failed to ${mode === 'edit' ? 'save' : 'create'} project`
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
formData.status = 'published'
|
||||
await handleSave()
|
||||
showPublishMenu = false
|
||||
}
|
||||
|
||||
async function handleUnpublish() {
|
||||
formData.status = 'draft'
|
||||
await handleSave()
|
||||
showPublishMenu = false
|
||||
}
|
||||
|
||||
function togglePublishMenu() {
|
||||
showPublishMenu = !showPublishMenu
|
||||
}
|
||||
|
||||
// Close menu when clicking outside
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.save-actions')) {
|
||||
showPublishMenu = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showPublishMenu) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<div class="header-left">
|
||||
<!-- Empty spacer for balance -->
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<AdminSegmentedControl
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={(value) => (activeTab = value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{#if !isLoading}
|
||||
<div class="save-actions">
|
||||
<button onclick={handleSave} disabled={isSaving} class="btn btn-primary save-button">
|
||||
{isSaving ? 'Saving...' : formData.status === 'published' ? 'Save' : 'Save Draft'}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary chevron-button"
|
||||
class:active={showPublishMenu}
|
||||
onclick={togglePublishMenu}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if showPublishMenu}
|
||||
<div class="publish-menu">
|
||||
{#if formData.status === 'published'}
|
||||
<button onclick={handleUnpublish} class="menu-item"> Unpublish </button>
|
||||
{:else}
|
||||
<button onclick={handlePublish} class="menu-item"> Publish </button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-container">
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading project...</div>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="success-message">{successMessage}</div>
|
||||
{/if}
|
||||
|
||||
<div class="tab-panels">
|
||||
<!-- Metadata Panel -->
|
||||
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
|
||||
<div class="form-content">
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}}
|
||||
>
|
||||
<ProjectMetadataForm bind:formData {validationErrors} />
|
||||
<ProjectBrandingForm
|
||||
bind:formData
|
||||
bind:logoUploadInProgress
|
||||
onLogoUpload={handleLogoUpload}
|
||||
onRemoveLogo={removeLogo}
|
||||
/>
|
||||
<ProjectStylingForm bind:formData {validationErrors} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Case Study Panel -->
|
||||
<div class="panel case-study-wrapper" class:active={activeTab === 'case-study'}>
|
||||
<div class="editor-content">
|
||||
<Editor
|
||||
bind:this={editorRef}
|
||||
bind:data={formData.caseStudyContent}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Write your case study here..."
|
||||
minHeight={400}
|
||||
autofocus={false}
|
||||
class="case-study-editor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr 250px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: $unit-2x;
|
||||
|
||||
.header-left {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 250px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-container {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 $unit-2x $unit-4x;
|
||||
box-sizing: border-box;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: 0 $unit-2x $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit $unit-3x;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background-color: $grey-10;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-button {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
padding-right: $unit-2x;
|
||||
}
|
||||
|
||||
.chevron-button {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding: $unit $unit;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
&.active {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&.active svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.publish-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: $unit;
|
||||
background: white;
|
||||
border-radius: $unit;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
min-width: 120px;
|
||||
z-index: 100;
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.925rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
color: $grey-10;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-95;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-panels {
|
||||
position: relative;
|
||||
|
||||
.panel {
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
background: white;
|
||||
border-radius: $unit-2x;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: $unit-6x;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d33;
|
||||
}
|
||||
|
||||
.error-message,
|
||||
.success-message {
|
||||
padding: $unit-3x;
|
||||
border-radius: $unit;
|
||||
margin-bottom: $unit-4x;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
max-width: 700px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
color: #d33;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #efe;
|
||||
color: #363;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
}
|
||||
|
||||
.case-study-wrapper {
|
||||
background: white;
|
||||
padding: 0;
|
||||
min-height: 80vh;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
height: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
/* The editor component will handle its own padding and scrolling */
|
||||
:global(.case-study-editor) {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
258
src/lib/components/admin/ProjectListItem.svelte
Normal file
258
src/lib/components/admin/ProjectListItem.svelte
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
interface Project {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
year: number
|
||||
client: string | null
|
||||
status: string
|
||||
logoUrl: string | null
|
||||
backgroundColor: string | null
|
||||
highlightColor: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
project: Project
|
||||
isDropdownActive?: boolean
|
||||
}
|
||||
|
||||
let { project, isDropdownActive = false }: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
toggleDropdown: { projectId: number; event: MouseEvent }
|
||||
edit: { project: Project; event: MouseEvent }
|
||||
togglePublish: { project: Project; event: MouseEvent }
|
||||
delete: { project: Project; event: MouseEvent }
|
||||
}>()
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (diffInSeconds < 60) return 'just now'
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
|
||||
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`
|
||||
if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)} months ago`
|
||||
return `${Math.floor(diffInSeconds / 31536000)} years ago`
|
||||
}
|
||||
|
||||
function handleProjectClick() {
|
||||
goto(`/admin/projects/${project.id}/edit`)
|
||||
}
|
||||
|
||||
function handleToggleDropdown(event: MouseEvent) {
|
||||
dispatch('toggleDropdown', { projectId: project.id, event })
|
||||
}
|
||||
|
||||
function handleEdit(event: MouseEvent) {
|
||||
dispatch('edit', { project, event })
|
||||
}
|
||||
|
||||
function handleTogglePublish(event: MouseEvent) {
|
||||
dispatch('togglePublish', { project, event })
|
||||
}
|
||||
|
||||
function handleDelete(event: MouseEvent) {
|
||||
dispatch('delete', { project, event })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="project-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={handleProjectClick}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleProjectClick()}
|
||||
>
|
||||
<div class="project-logo" style="background-color: {project.backgroundColor || '#F5F5F5'}">
|
||||
{#if project.logoUrl}
|
||||
<img src={project.logoUrl} alt="{project.title} logo" class="logo-image" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="project-info">
|
||||
<h3 class="project-title">{project.title}</h3>
|
||||
<div class="project-metadata">
|
||||
<span class="status" class:published={project.status === 'published'}>
|
||||
{project.status === 'published' ? 'Published' : 'Not published'}
|
||||
</span>
|
||||
<span class="separator">·</span>
|
||||
<span class="updated">Last updated {formatRelativeTime(project.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-container">
|
||||
<button class="action-button" onclick={handleToggleDropdown} aria-label="Project actions">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="10" cy="4" r="1.5" fill="currentColor" />
|
||||
<circle cx="10" cy="10" r="1.5" fill="currentColor" />
|
||||
<circle cx="10" cy="16" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isDropdownActive}
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" onclick={handleEdit}> Edit project </button>
|
||||
<button class="dropdown-item" onclick={handleTogglePublish}>
|
||||
{project.status === 'published' ? 'Unpublish' : 'Publish'} project
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item delete" onclick={handleDelete}> Delete project </button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.project-item {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
padding: $unit-2x;
|
||||
background: white;
|
||||
border-radius: $unit-2x;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-95;
|
||||
}
|
||||
}
|
||||
|
||||
.project-logo {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $unit;
|
||||
padding: $unit-2x;
|
||||
box-sizing: border-box;
|
||||
|
||||
.logo-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.project-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.project-metadata {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
.status {
|
||||
&.published {
|
||||
color: #22c55e; // Green color for published status
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: $grey-60;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: $unit;
|
||||
cursor: pointer;
|
||||
color: $grey-30;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: $unit-half;
|
||||
background: white;
|
||||
border: 1px solid $grey-85;
|
||||
border-radius: $unit;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
min-width: 180px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-20;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-95;
|
||||
}
|
||||
|
||||
&.delete {
|
||||
color: $red-60;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background-color: $grey-90;
|
||||
margin: $unit-half 0;
|
||||
}
|
||||
</style>
|
||||
100
src/lib/components/admin/ProjectMetadataForm.svelte
Normal file
100
src/lib/components/admin/ProjectMetadataForm.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<script lang="ts">
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import type { ProjectFormData } from '$lib/types/project'
|
||||
|
||||
interface Props {
|
||||
formData: ProjectFormData
|
||||
validationErrors: Record<string, string>
|
||||
}
|
||||
|
||||
let { formData = $bindable(), validationErrors }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="form-section">
|
||||
<FormFieldWrapper label="Title" required error={validationErrors.title}>
|
||||
<input type="text" bind:value={formData.title} required placeholder="Project title" />
|
||||
</FormFieldWrapper>
|
||||
|
||||
<FormFieldWrapper label="Description" error={validationErrors.description}>
|
||||
<textarea
|
||||
bind:value={formData.description}
|
||||
rows="3"
|
||||
placeholder="Short description for project cards"
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<div class="form-row">
|
||||
<FormFieldWrapper label="Year" required error={validationErrors.year}>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={formData.year}
|
||||
required
|
||||
min="1990"
|
||||
max={new Date().getFullYear() + 1}
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<FormFieldWrapper label="Client" error={validationErrors.client}>
|
||||
<input type="text" bind:value={formData.client} placeholder="Client or company name" />
|
||||
</FormFieldWrapper>
|
||||
</div>
|
||||
|
||||
<FormFieldWrapper label="External URL" error={validationErrors.externalUrl}>
|
||||
<input type="url" bind:value={formData.externalUrl} placeholder="https://example.com" />
|
||||
</FormFieldWrapper>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.form-section {
|
||||
margin-bottom: $unit-6x;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $unit-2x;
|
||||
padding-bottom: $unit-3x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
:global(.form-field) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='url'],
|
||||
input[type='number'],
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: calc($unit * 1.5);
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $unit;
|
||||
font-size: 1rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: border-color 0.2s ease;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-40;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
</style>
|
||||
125
src/lib/components/admin/ProjectStylingForm.svelte
Normal file
125
src/lib/components/admin/ProjectStylingForm.svelte
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<script lang="ts">
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import type { ProjectFormData } from '$lib/types/project'
|
||||
|
||||
interface Props {
|
||||
formData: ProjectFormData
|
||||
validationErrors: Record<string, string>
|
||||
}
|
||||
|
||||
let { formData = $bindable(), validationErrors }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Styling</h2>
|
||||
|
||||
<div class="form-row">
|
||||
<FormFieldWrapper
|
||||
label="Background Color"
|
||||
helpText="Hex color for project card"
|
||||
error={validationErrors.backgroundColor}
|
||||
>
|
||||
<div class="color-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={formData.backgroundColor}
|
||||
placeholder="#FFFFFF"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
/>
|
||||
{#if formData.backgroundColor}
|
||||
<div
|
||||
class="color-preview"
|
||||
style="background-color: {formData.backgroundColor}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<FormFieldWrapper
|
||||
label="Highlight Color"
|
||||
helpText="Accent color for the project"
|
||||
error={validationErrors.highlightColor}
|
||||
>
|
||||
<div class="color-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={formData.highlightColor}
|
||||
placeholder="#000000"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
/>
|
||||
{#if formData.highlightColor}
|
||||
<div class="color-preview" style="background-color: {formData.highlightColor}"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.form-section {
|
||||
margin-bottom: $unit-6x;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $unit-2x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
:global(.form-field) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.color-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: calc($unit * 1.5);
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $unit;
|
||||
font-size: 1rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: border-color 0.2s ease;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-40;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: $unit;
|
||||
border: 1px solid $grey-80;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -45,10 +45,11 @@
|
|||
}
|
||||
|
||||
.edra-editor {
|
||||
padding: var(--edra-padding);
|
||||
padding: 0 var(--edra-padding);
|
||||
flex-grow: 1;
|
||||
padding-left: 2rem;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.edra-toolbar {
|
||||
|
|
@ -113,7 +114,7 @@
|
|||
|
||||
.separator {
|
||||
width: var(--edra-separator-width);
|
||||
background-color: var(--edra-separator-color);
|
||||
/* background-color: var(--edra-separator-color); */
|
||||
}
|
||||
|
||||
.edra-media-placeholder-wrapper {
|
||||
|
|
|
|||
25
src/lib/schemas/project.ts
Normal file
25
src/lib/schemas/project.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const projectSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().optional(),
|
||||
year: z
|
||||
.number()
|
||||
.min(1990)
|
||||
.max(new Date().getFullYear() + 1),
|
||||
client: z.string().optional(),
|
||||
externalUrl: z.string().url().optional().or(z.literal('')),
|
||||
backgroundColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-Fa-f]{6}$/)
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
highlightColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-Fa-f]{6}$/)
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
status: z.enum(['draft', 'published'])
|
||||
})
|
||||
|
||||
export type ProjectSchema = z.infer<typeof projectSchema>
|
||||
58
src/lib/types/project.ts
Normal file
58
src/lib/types/project.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export interface Project {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
subtitle: string | null
|
||||
description: string | null
|
||||
year: number
|
||||
client: string | null
|
||||
role: string | null
|
||||
technologies: string[] | null
|
||||
featuredImage: string | null
|
||||
logoUrl: string | null
|
||||
gallery: any[] | null
|
||||
externalUrl: string | null
|
||||
caseStudyContent: any | null
|
||||
backgroundColor: string | null
|
||||
highlightColor: string | null
|
||||
displayOrder: number
|
||||
status: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
publishedAt?: string | null
|
||||
}
|
||||
|
||||
export interface ProjectFormData {
|
||||
title: string
|
||||
subtitle: string
|
||||
description: string
|
||||
year: number
|
||||
client: string
|
||||
role: string
|
||||
technologies: string
|
||||
externalUrl: string
|
||||
backgroundColor: string
|
||||
highlightColor: string
|
||||
logoUrl: string
|
||||
status: 'draft' | 'published'
|
||||
caseStudyContent: any
|
||||
}
|
||||
|
||||
export const defaultProjectFormData: ProjectFormData = {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
year: new Date().getFullYear(),
|
||||
client: '',
|
||||
role: '',
|
||||
technologies: '',
|
||||
externalUrl: '',
|
||||
backgroundColor: '',
|
||||
highlightColor: '',
|
||||
logoUrl: '',
|
||||
status: 'draft',
|
||||
caseStudyContent: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }]
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
import { page } from '$app/stores'
|
||||
import { onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import AdminSegmentedController from '$lib/components/admin/AdminSegmentedController.svelte'
|
||||
import AdminNavBar from '$lib/components/admin/AdminNavBar.svelte'
|
||||
|
||||
let { children } = $props()
|
||||
|
||||
|
|
@ -36,9 +36,7 @@
|
|||
{:else}
|
||||
<!-- Authenticated, show admin layout -->
|
||||
<div class="admin-container">
|
||||
<header class="admin-header">
|
||||
<AdminSegmentedController />
|
||||
</header>
|
||||
<AdminNavBar />
|
||||
|
||||
<main class="admin-content">
|
||||
{@render children()}
|
||||
|
|
@ -61,18 +59,12 @@
|
|||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: $unit-6x 0 $unit-4x;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
background-color: $bg-color;
|
||||
padding-top: $unit-4x;
|
||||
padding-bottom: $unit-6x;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,229 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import Page from '$lib/components/Page.svelte'
|
||||
|
||||
interface Stats {
|
||||
projects: number
|
||||
posts: number
|
||||
albums: number
|
||||
photos: number
|
||||
media: number
|
||||
}
|
||||
|
||||
let stats = $state<Stats | null>(null)
|
||||
let isLoading = $state(true)
|
||||
let error = $state('')
|
||||
|
||||
onMount(async () => {
|
||||
await loadStats()
|
||||
})
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) return
|
||||
|
||||
// Fetch counts from each endpoint
|
||||
const [projectsRes, postsRes, albumsRes, mediaRes] = await Promise.all([
|
||||
fetch('/api/projects?limit=1', {
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
}),
|
||||
fetch('/api/posts?limit=1', {
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
}),
|
||||
fetch('/api/albums?limit=1', {
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
}),
|
||||
fetch('/api/media?limit=1', {
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
})
|
||||
])
|
||||
|
||||
const projectsData = projectsRes.ok ? await projectsRes.json() : { pagination: { total: 0 } }
|
||||
const postsData = postsRes.ok ? await postsRes.json() : { pagination: { total: 0 } }
|
||||
const albumsData = albumsRes.ok ? await albumsRes.json() : { pagination: { total: 0 } }
|
||||
const mediaData = mediaRes.ok ? await mediaRes.json() : { pagination: { total: 0 } }
|
||||
|
||||
// TODO: Get photo count once we have that endpoint
|
||||
const photoCount = 0
|
||||
|
||||
stats = {
|
||||
projects: projectsData.pagination?.total || 0,
|
||||
posts: postsData.pagination?.total || 0,
|
||||
albums: albumsData.pagination?.total || 0,
|
||||
photos: photoCount,
|
||||
media: mediaData.pagination?.total || 0
|
||||
}
|
||||
} catch (err) {
|
||||
error = 'Failed to load statistics'
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<header slot="header">
|
||||
<h1>Dashboard</h1>
|
||||
</header>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading statistics...</div>
|
||||
{:else if error}
|
||||
<div class="error">{error}</div>
|
||||
{:else if stats}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">💼</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{stats.projects}</div>
|
||||
<div class="stat-label">Projects</div>
|
||||
</div>
|
||||
<a href="/admin/projects" class="stat-link">View all →</a>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🌟</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{stats.posts}</div>
|
||||
<div class="stat-label">Universe Posts</div>
|
||||
</div>
|
||||
<a href="/admin/posts" class="stat-link">View all →</a>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📸</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{stats.albums}</div>
|
||||
<div class="stat-label">Photo Albums</div>
|
||||
</div>
|
||||
<a href="/admin/albums" class="stat-link">View all →</a>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🖼️</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{stats.media}</div>
|
||||
<div class="stat-label">Media Files</div>
|
||||
</div>
|
||||
<a href="/admin/media" class="stat-link">View all →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="quick-actions">
|
||||
<h2>Quick Actions</h2>
|
||||
<div class="action-grid">
|
||||
<a href="/admin/projects/new" class="action-card">
|
||||
<span class="action-icon">➕</span>
|
||||
<span>New Project</span>
|
||||
</a>
|
||||
<a href="/admin/posts/new" class="action-card">
|
||||
<span class="action-icon">✏️</span>
|
||||
<span>Write Post</span>
|
||||
</a>
|
||||
<a href="/admin/albums/new" class="action-card">
|
||||
<span class="action-icon">📷</span>
|
||||
<span>Create Album</span>
|
||||
</a>
|
||||
<a href="/admin/media" class="action-card">
|
||||
<span class="action-icon">⬆️</span>
|
||||
<span>Upload Media</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</Page>
|
||||
<AdminPage>
|
||||
<div class="action-grid">
|
||||
<a href="/admin/projects/new" class="action-card">
|
||||
<span class="action-icon">➕</span>
|
||||
<span>New Project</span>
|
||||
</a>
|
||||
<a href="/admin/posts/new" class="action-card">
|
||||
<span class="action-icon">✏️</span>
|
||||
<span>Write Post</span>
|
||||
</a>
|
||||
<a href="/admin/albums/new" class="action-card">
|
||||
<span class="action-icon">📷</span>
|
||||
<span>Create Album</span>
|
||||
</a>
|
||||
<a href="/admin/media" class="action-card">
|
||||
<span class="action-icon">⬆️</span>
|
||||
<span>Upload Media</span>
|
||||
</a>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: $unit-6x;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d33;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: $unit-3x;
|
||||
margin-bottom: $unit-6x;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: $grey-95;
|
||||
border-radius: $unit-2x;
|
||||
padding: $unit-4x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-90;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: $unit-half;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: $grey-40;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.stat-link {
|
||||
color: $grey-30;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
&:hover {
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import Page from '$lib/components/Page.svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
let media = $state<Media[]>([])
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<h1>Media Library</h1>
|
||||
<div class="header-actions">
|
||||
|
|
@ -207,7 +207,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Page>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import Page from '$lib/components/Page.svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import DataTable from '$lib/components/admin/DataTable.svelte'
|
||||
import PostDropdown from '$lib/components/admin/PostDropdown.svelte'
|
||||
|
||||
interface Post {
|
||||
id: number
|
||||
|
|
@ -139,11 +140,11 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<h1>Posts</h1>
|
||||
<div class="header-actions">
|
||||
<a href="/admin/posts/new" class="btn btn-primary">New Post</a>
|
||||
<PostDropdown />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -171,7 +172,7 @@
|
|||
onRowClick={handleRowClick}
|
||||
/>
|
||||
{/if}
|
||||
</Page>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
import Page from '$lib/components/Page.svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import FormField from '$lib/components/admin/FormField.svelte'
|
||||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
||||
import Editor from '$lib/components/admin/Editor.svelte'
|
||||
|
|
@ -19,9 +19,23 @@
|
|||
let slug = ''
|
||||
let excerpt = ''
|
||||
let linkUrl = ''
|
||||
let linkDescription = ''
|
||||
let content: JSONContent = { type: 'doc', content: [] }
|
||||
let tags: string[] = []
|
||||
let tagInput = ''
|
||||
let showMetadata = false
|
||||
let isPublishDropdownOpen = false
|
||||
let publishButtonRef: HTMLButtonElement
|
||||
|
||||
const postTypeConfig = {
|
||||
blog: { icon: '📝', label: 'Blog Post', showTitle: true, showContent: true },
|
||||
microblog: { icon: '💭', label: 'Microblog', showTitle: false, showContent: true },
|
||||
link: { icon: '🔗', label: 'Link', showTitle: true, showContent: false },
|
||||
photo: { icon: '📷', label: 'Photo', showTitle: true, showContent: false },
|
||||
album: { icon: '🖼️', label: 'Album', showTitle: true, showContent: false }
|
||||
}
|
||||
|
||||
let config = $derived(postTypeConfig[postType])
|
||||
|
||||
onMount(async () => {
|
||||
await loadPost()
|
||||
|
|
@ -42,11 +56,12 @@
|
|||
post = await response.json()
|
||||
// Populate form fields
|
||||
title = post.title || ''
|
||||
postType = post.type
|
||||
postType = post.type || post.postType
|
||||
status = post.status
|
||||
slug = post.slug || ''
|
||||
excerpt = post.excerpt || ''
|
||||
linkUrl = post.link_url || ''
|
||||
linkUrl = post.link_url || post.linkUrl || ''
|
||||
linkDescription = post.link_description || post.linkDescription || ''
|
||||
content = post.content || { type: 'doc', content: [] }
|
||||
tags = post.tags || []
|
||||
}
|
||||
|
|
@ -68,7 +83,7 @@
|
|||
tags = tags.filter(t => t !== tag)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
async function handleSave(publishStatus?: 'draft' | 'published') {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
|
|
@ -77,13 +92,14 @@
|
|||
|
||||
saving = true
|
||||
const postData = {
|
||||
title,
|
||||
title: config.showTitle ? title : null,
|
||||
slug,
|
||||
type: postType,
|
||||
status,
|
||||
content,
|
||||
status: publishStatus || status,
|
||||
content: config.showContent ? content : null,
|
||||
excerpt: postType === 'blog' ? excerpt : undefined,
|
||||
link_url: postType === 'link' ? linkUrl : undefined,
|
||||
link_description: postType === 'link' ? linkDescription : undefined,
|
||||
tags
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +115,9 @@
|
|||
|
||||
if (response.ok) {
|
||||
post = await response.json()
|
||||
if (publishStatus) {
|
||||
status = publishStatus
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save post:', error)
|
||||
|
|
@ -129,18 +148,74 @@
|
|||
console.error('Failed to delete post:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePublishDropdown(event: MouseEvent) {
|
||||
if (!publishButtonRef?.contains(event.target as Node)) {
|
||||
isPublishDropdownOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isPublishDropdownOpen) {
|
||||
document.addEventListener('click', handlePublishDropdown)
|
||||
return () => document.removeEventListener('click', handlePublishDropdown)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
{#if !loading && post}
|
||||
<h1>Edit Post</h1>
|
||||
<div class="actions">
|
||||
<button class="btn btn-danger" on:click={handleDelete}>Delete</button>
|
||||
<button class="btn btn-secondary" on:click={() => goto('/admin/posts')}>Cancel</button>
|
||||
<button class="btn btn-primary" on:click={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
<div class="header-left">
|
||||
<button class="btn-icon" onclick={() => goto('/admin/posts')}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1>{config.icon} Edit {config.label}</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-text" onclick={handleDelete}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 4L12 12M4 12L12 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
<button class="btn btn-text" onclick={() => showMetadata = !showMetadata}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 4V8L10 10M14 8C14 11.3137 11.3137 14 8 14C4.68629 14 2 11.3137 2 8C2 4.68629 4.68629 2 8 2C11.3137 2 8 4.68629 8 8Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Metadata
|
||||
</button>
|
||||
{#if status === 'draft'}
|
||||
<div class="publish-dropdown">
|
||||
<button
|
||||
bind:this={publishButtonRef}
|
||||
class="btn btn-primary"
|
||||
onclick={(e) => { e.stopPropagation(); isPublishDropdownOpen = !isPublishDropdownOpen }}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Publish'}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if isPublishDropdownOpen}
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" onclick={() => handleSave('published')}>
|
||||
<span>Publish now</span>
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => handleSave('draft')}>
|
||||
<span>Keep as draft</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button class="btn btn-primary" onclick={() => handleSave()} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
|
@ -150,89 +225,122 @@
|
|||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if post}
|
||||
<div class="post-editor">
|
||||
<div class="form-section">
|
||||
<FormFieldWrapper label="Post Type" required>
|
||||
<select bind:value={postType} class="form-select">
|
||||
<option value="blog">📝 Blog Post</option>
|
||||
<option value="microblog">💭 Microblog</option>
|
||||
<option value="link">🔗 Link</option>
|
||||
<option value="photo">📷 Photo</option>
|
||||
<option value="album">🖼️ Photo Album</option>
|
||||
</select>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<FormField label="Title" bind:value={title} required />
|
||||
<div class="post-composer">
|
||||
<div class="main-content">
|
||||
{#if config.showTitle}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="Title"
|
||||
class="title-input"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<FormField label="Slug" bind:value={slug} required />
|
||||
|
||||
{#if postType === 'blog'}
|
||||
<FormFieldWrapper label="Excerpt">
|
||||
<textarea
|
||||
bind:value={excerpt}
|
||||
class="form-textarea"
|
||||
rows="3"
|
||||
placeholder="Brief description of the post..."
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
{/if}
|
||||
|
||||
{#if postType === 'link'}
|
||||
<FormField label="Link URL" bind:value={linkUrl} type="url" required />
|
||||
{/if}
|
||||
|
||||
<FormFieldWrapper label="Status">
|
||||
<select bind:value={status} class="form-select">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
</select>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<FormFieldWrapper label="Tags">
|
||||
<div class="tag-input-wrapper">
|
||||
<div class="link-fields">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={tagInput}
|
||||
on:keydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
||||
placeholder="Add tags..."
|
||||
class="form-input"
|
||||
type="url"
|
||||
bind:value={linkUrl}
|
||||
placeholder="https://example.com"
|
||||
class="link-url-input"
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
bind:value={linkDescription}
|
||||
class="link-description"
|
||||
rows="3"
|
||||
placeholder="What makes this link interesting?"
|
||||
/>
|
||||
<button type="button" on:click={addTag} class="btn btn-secondary">Add</button>
|
||||
</div>
|
||||
{#if tags.length > 0}
|
||||
<div class="tags">
|
||||
{#each tags as tag}
|
||||
<span class="tag">
|
||||
{tag}
|
||||
<button on:click={() => removeTag(tag)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
{:else if postType === 'photo'}
|
||||
<div class="photo-upload">
|
||||
<div class="photo-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M40 14H31.5L28 10H20L16.5 14H8C5.8 14 4 15.8 4 18V34C4 36.2 5.8 38 8 38H40C42.2 38 44 36.2 44 34V18C44 15.8 42.2 14 40 14ZM24 32C19.6 32 16 28.4 16 24C16 19.6 19.6 16 24 16C28.4 16 32 19.6 32 24C32 28.4 28.4 32 24 32Z" fill="currentColor" opacity="0.1"/>
|
||||
<path d="M24 28C26.2091 28 28 26.2091 28 24C28 21.7909 26.2091 20 24 20C21.7909 20 20 21.7909 20 24C20 26.2091 21.7909 28 24 28Z" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
<p>Click to upload photo</p>
|
||||
</div>
|
||||
{/if}
|
||||
</FormFieldWrapper>
|
||||
|
||||
<div class="metadata">
|
||||
<p>Created: {new Date(post.created_at).toLocaleString()}</p>
|
||||
<p>Updated: {new Date(post.updated_at).toLocaleString()}</p>
|
||||
{#if post.published_at}
|
||||
<p>Published: {new Date(post.published_at).toLocaleString()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if postType === 'album'}
|
||||
<div class="album-upload">
|
||||
<div class="album-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="8" y="8" width="24" height="24" rx="2" fill="currentColor" opacity="0.1"/>
|
||||
<rect x="16" y="16" width="24" height="24" rx="2" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M16 24L20 20L24 24L32 16L40 24V38C40 39.1046 39.1046 40 38 40H18C16.8954 40 16 39.1046 16 38V24Z" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
<p>Click to upload photos</p>
|
||||
<span class="album-hint">Select multiple photos</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if config.showContent}
|
||||
<div class="editor-wrapper">
|
||||
<Editor bind:data={content} placeholder="Continue writing..." />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if postType !== 'link'}
|
||||
<div class="editor-section">
|
||||
<h2>Content</h2>
|
||||
<Editor bind:data={content} />
|
||||
</div>
|
||||
|
||||
{#if showMetadata}
|
||||
<aside class="metadata-sidebar">
|
||||
<h3>Post Settings</h3>
|
||||
|
||||
<FormField label="Slug" bind:value={slug} />
|
||||
|
||||
{#if postType === 'blog'}
|
||||
<FormFieldWrapper label="Excerpt">
|
||||
<textarea
|
||||
bind:value={excerpt}
|
||||
class="form-textarea"
|
||||
rows="3"
|
||||
placeholder="Brief description..."
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
{/if}
|
||||
|
||||
<FormFieldWrapper label="Tags">
|
||||
<div class="tag-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={tagInput}
|
||||
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
||||
placeholder="Add tags..."
|
||||
class="form-input"
|
||||
/>
|
||||
<button type="button" onclick={addTag} class="btn btn-small">Add</button>
|
||||
</div>
|
||||
{#if tags.length > 0}
|
||||
<div class="tags">
|
||||
{#each tags as tag}
|
||||
<span class="tag">
|
||||
{tag}
|
||||
<button onclick={() => removeTag(tag)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</FormFieldWrapper>
|
||||
|
||||
<div class="metadata">
|
||||
<p>Created: {new Date(post.created_at || post.createdAt).toLocaleString()}</p>
|
||||
<p>Updated: {new Date(post.updated_at || post.updatedAt).toLocaleString()}</p>
|
||||
{#if post.published_at || post.publishedAt}
|
||||
<p>Published: {new Date(post.published_at || post.publishedAt).toLocaleString()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="error">Post not found</div>
|
||||
{/if}
|
||||
</Page>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
@ -240,120 +348,69 @@
|
|||
min-height: 400px;
|
||||
}
|
||||
|
||||
header {
|
||||
.header-left {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
gap: $unit-2x;
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-90;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.post-editor {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
grid-template-columns: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-select,
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: 1px solid $grey-80;
|
||||
|
||||
.btn-text {
|
||||
padding: $unit $unit-2x;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
border-radius: 8px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
background-color: $grey-90;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-50;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.tag-input-wrapper {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: $unit $unit-2x;
|
||||
background: $grey-80;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metadata {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
p {
|
||||
margin: $unit-half 0;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-section {
|
||||
width: 100%;
|
||||
min-width: 0; // Prevent overflow
|
||||
transition: all 0.2s ease;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 1rem;
|
||||
&:hover {
|
||||
background: $grey-90;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.publish-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: none;
|
||||
|
|
@ -362,43 +419,301 @@
|
|||
font-size: 0.925rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
&.btn-primary {
|
||||
background-color: $grey-10;
|
||||
color: white;
|
||||
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
background-color: $grey-80;
|
||||
color: $grey-10;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-60;
|
||||
}
|
||||
|
||||
&.btn-small {
|
||||
padding: $unit $unit-2x;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background-color: #dc2626;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + $unit);
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-width: 150px;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
padding: $unit-2x;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.2s ease;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-10;
|
||||
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $grey-90;
|
||||
}
|
||||
}
|
||||
|
||||
.post-composer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $unit-4x;
|
||||
|
||||
&:has(.metadata-sidebar) {
|
||||
grid-template-columns: 1fr 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
color: $grey-10;
|
||||
background: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $grey-60;
|
||||
}
|
||||
}
|
||||
|
||||
.link-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.link-url-input {
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 8px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 1.125rem;
|
||||
background-color: $grey-95;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-50;
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.link-description {
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 8px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
background-color: $grey-95;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-50;
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-upload,
|
||||
.album-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.photo-placeholder,
|
||||
.album-placeholder {
|
||||
border: 2px dashed $grey-80;
|
||||
border-radius: 12px;
|
||||
padding: $unit-8x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: $grey-95;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-60;
|
||||
background: $grey-90;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: $grey-50;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: $grey-30;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.album-hint {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-50;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.metadata-sidebar {
|
||||
background: $grey-95;
|
||||
border-radius: 12px;
|
||||
padding: $unit-3x;
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: $unit-3x;
|
||||
|
||||
h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
> * + * {
|
||||
margin-top: $unit-3x;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: $unit-2x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 8px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
background-color: white;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-50;
|
||||
}
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: $unit $unit-2x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 8px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
background-color: white;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-50;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-input-wrapper {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $unit;
|
||||
margin-top: $unit;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px $unit-2x;
|
||||
background: $grey-80;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.metadata {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
p {
|
||||
margin: $unit-half 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
color: $grey-40;
|
||||
padding: 2rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
.post-composer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metadata-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,30 +1,55 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import Page from '$lib/components/Page.svelte'
|
||||
import { page } from '$app/stores'
|
||||
import { onMount } from 'svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import FormField from '$lib/components/admin/FormField.svelte'
|
||||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
||||
import Editor from '$lib/components/admin/Editor.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
let title = ''
|
||||
// Get post type from URL params
|
||||
let postType: 'blog' | 'microblog' | 'link' | 'photo' | 'album' = 'blog'
|
||||
|
||||
let title = ''
|
||||
let status: 'draft' | 'published' = 'draft'
|
||||
let slug = ''
|
||||
let excerpt = ''
|
||||
let linkUrl = ''
|
||||
let linkDescription = ''
|
||||
let content: JSONContent = { type: 'doc', content: [] }
|
||||
let tags: string[] = []
|
||||
let tagInput = ''
|
||||
let showMetadata = false
|
||||
let isPublishDropdownOpen = false
|
||||
let publishButtonRef: HTMLButtonElement
|
||||
|
||||
$: {
|
||||
// Auto-generate slug from title
|
||||
const postTypeConfig = {
|
||||
blog: { icon: '📝', label: 'Blog Post', showTitle: true, showContent: true },
|
||||
microblog: { icon: '💭', label: 'Microblog', showTitle: false, showContent: true },
|
||||
link: { icon: '🔗', label: 'Link', showTitle: true, showContent: false },
|
||||
photo: { icon: '📷', label: 'Photo', showTitle: true, showContent: false },
|
||||
album: { icon: '🖼️', label: 'Album', showTitle: true, showContent: false }
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const type = $page.url.searchParams.get('type')
|
||||
if (type && type in postTypeConfig) {
|
||||
postType = type as typeof postType
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-generate slug from title
|
||||
$effect(() => {
|
||||
if (title && !slug) {
|
||||
slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let config = $derived(postTypeConfig[postType])
|
||||
|
||||
function addTag() {
|
||||
if (tagInput && !tags.includes(tagInput)) {
|
||||
|
|
@ -37,7 +62,7 @@
|
|||
tags = tags.filter(t => t !== tag)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleSubmit(publishStatus: 'draft' | 'published') {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
|
|
@ -45,13 +70,14 @@
|
|||
}
|
||||
|
||||
const postData = {
|
||||
title,
|
||||
title: config.showTitle ? title : null,
|
||||
slug,
|
||||
type: postType,
|
||||
status,
|
||||
content,
|
||||
status: publishStatus,
|
||||
content: config.showContent ? content : null,
|
||||
excerpt: postType === 'blog' ? excerpt : undefined,
|
||||
link_url: postType === 'link' ? linkUrl : undefined,
|
||||
link_description: postType === 'link' ? linkDescription : undefined,
|
||||
tags
|
||||
}
|
||||
|
||||
|
|
@ -73,193 +99,231 @@
|
|||
console.error('Failed to create post:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePublishDropdown(event: MouseEvent) {
|
||||
if (!publishButtonRef?.contains(event.target as Node)) {
|
||||
isPublishDropdownOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isPublishDropdownOpen) {
|
||||
document.addEventListener('click', handlePublishDropdown)
|
||||
return () => document.removeEventListener('click', handlePublishDropdown)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<h1>New Post</h1>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" on:click={() => goto('/admin/posts')}>Cancel</button>
|
||||
<button class="btn btn-primary" on:click={handleSubmit}>Create Post</button>
|
||||
<div class="header-left">
|
||||
<button class="btn-icon" onclick={() => goto('/admin/posts')}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1>{config.icon} New {config.label}</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-text" onclick={() => showMetadata = !showMetadata}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 4V8L10 10M14 8C14 11.3137 11.3137 14 8 14C4.68629 14 2 11.3137 2 8C2 4.68629 4.68629 2 8 2C11.3137 2 8 4.68629 8 8Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Metadata
|
||||
</button>
|
||||
<div class="publish-dropdown">
|
||||
<button
|
||||
bind:this={publishButtonRef}
|
||||
class="btn btn-primary"
|
||||
onclick={(e) => { e.stopPropagation(); isPublishDropdownOpen = !isPublishDropdownOpen }}
|
||||
>
|
||||
Post
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if isPublishDropdownOpen}
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" onclick={() => handleSubmit('published')}>
|
||||
<span>Publish now</span>
|
||||
</button>
|
||||
<button class="dropdown-item" onclick={() => handleSubmit('draft')}>
|
||||
<span>Save as draft</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="post-editor">
|
||||
<div class="form-section">
|
||||
<FormFieldWrapper label="Post Type" required>
|
||||
<select bind:value={postType} class="form-select">
|
||||
<option value="blog">📝 Blog Post</option>
|
||||
<option value="microblog">💭 Microblog</option>
|
||||
<option value="link">🔗 Link</option>
|
||||
<option value="photo">📷 Photo</option>
|
||||
<option value="album">🖼️ Photo Album</option>
|
||||
</select>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<FormField label="Title" bind:value={title} required />
|
||||
|
||||
<FormField label="Slug" bind:value={slug} required />
|
||||
|
||||
{#if postType === 'blog'}
|
||||
<FormFieldWrapper label="Excerpt">
|
||||
<textarea
|
||||
bind:value={excerpt}
|
||||
class="form-textarea"
|
||||
rows="3"
|
||||
placeholder="Brief description of the post..."
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
{/if}
|
||||
|
||||
{#if postType === 'link'}
|
||||
<FormField label="Link URL" bind:value={linkUrl} type="url" required />
|
||||
{/if}
|
||||
|
||||
<FormFieldWrapper label="Status">
|
||||
<select bind:value={status} class="form-select">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
</select>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<FormFieldWrapper label="Tags">
|
||||
<div class="tag-input-wrapper">
|
||||
<div class="post-composer">
|
||||
<div class="main-content">
|
||||
{#if config.showTitle}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={tagInput}
|
||||
on:keydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
||||
placeholder="Add tags..."
|
||||
class="form-input"
|
||||
bind:value={title}
|
||||
placeholder="Title"
|
||||
class="title-input"
|
||||
/>
|
||||
<button type="button" on:click={addTag} class="btn btn-secondary">Add</button>
|
||||
</div>
|
||||
{#if tags.length > 0}
|
||||
<div class="tags">
|
||||
{#each tags as tag}
|
||||
<span class="tag">
|
||||
{tag}
|
||||
<button on:click={() => removeTag(tag)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if postType === 'link'}
|
||||
<div class="link-fields">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={linkUrl}
|
||||
placeholder="https://example.com"
|
||||
class="link-url-input"
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
bind:value={linkDescription}
|
||||
class="link-description"
|
||||
rows="3"
|
||||
placeholder="What makes this link interesting?"
|
||||
/>
|
||||
</div>
|
||||
{:else if postType === 'photo'}
|
||||
<div class="photo-upload">
|
||||
<div class="photo-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M40 14H31.5L28 10H20L16.5 14H8C5.8 14 4 15.8 4 18V34C4 36.2 5.8 38 8 38H40C42.2 38 44 36.2 44 34V18C44 15.8 42.2 14 40 14ZM24 32C19.6 32 16 28.4 16 24C16 19.6 19.6 16 24 16C28.4 16 32 19.6 32 24C32 28.4 28.4 32 24 32Z" fill="currentColor" opacity="0.1"/>
|
||||
<path d="M24 28C26.2091 28 28 26.2091 28 24C28 21.7909 26.2091 20 24 20C21.7909 20 20 21.7909 20 24C20 26.2091 21.7909 28 24 28Z" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
<p>Click to upload photo</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if postType === 'album'}
|
||||
<div class="album-upload">
|
||||
<div class="album-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<rect x="8" y="8" width="24" height="24" rx="2" fill="currentColor" opacity="0.1"/>
|
||||
<rect x="16" y="16" width="24" height="24" rx="2" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M16 24L20 20L24 24L32 16L40 24V38C40 39.1046 39.1046 40 38 40H18C16.8954 40 16 39.1046 16 38V24Z" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
<p>Click to upload photos</p>
|
||||
<span class="album-hint">Select multiple photos</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if config.showContent}
|
||||
<div class="editor-wrapper">
|
||||
<Editor bind:data={content} placeholder="Start writing..." />
|
||||
</div>
|
||||
{/if}
|
||||
</FormFieldWrapper>
|
||||
</div>
|
||||
|
||||
{#if postType !== 'link'}
|
||||
<div class="editor-section">
|
||||
<h2>Content</h2>
|
||||
<Editor bind:data={content} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
{#if showMetadata}
|
||||
<aside class="metadata-sidebar">
|
||||
<h3>Post Settings</h3>
|
||||
|
||||
<FormField label="Slug" bind:value={slug} />
|
||||
|
||||
{#if postType === 'blog'}
|
||||
<FormFieldWrapper label="Excerpt">
|
||||
<textarea
|
||||
bind:value={excerpt}
|
||||
class="form-textarea"
|
||||
rows="3"
|
||||
placeholder="Brief description..."
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
{/if}
|
||||
|
||||
<FormFieldWrapper label="Tags">
|
||||
<div class="tag-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={tagInput}
|
||||
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
||||
placeholder="Add tags..."
|
||||
class="form-input"
|
||||
/>
|
||||
<button type="button" onclick={addTag} class="btn btn-small">Add</button>
|
||||
</div>
|
||||
{#if tags.length > 0}
|
||||
<div class="tags">
|
||||
{#each tags as tag}
|
||||
<span class="tag">
|
||||
{tag}
|
||||
<button onclick={() => removeTag(tag)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</FormFieldWrapper>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
gap: $unit-2x;
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-90;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.post-editor {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
grid-template-columns: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-select,
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: 1px solid $grey-80;
|
||||
|
||||
.btn-text {
|
||||
padding: $unit $unit-2x;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
border-radius: 8px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
background-color: $grey-90;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-50;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.tag-input-wrapper {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: $unit $unit-2x;
|
||||
background: $grey-80;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-section {
|
||||
width: 100%;
|
||||
min-width: 0; // Prevent overflow
|
||||
transition: all 0.2s ease;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 1rem;
|
||||
&:hover {
|
||||
background: $grey-90;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.publish-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: none;
|
||||
|
|
@ -268,23 +332,279 @@
|
|||
font-size: 0.925rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
|
||||
&.btn-primary {
|
||||
background-color: $grey-10;
|
||||
color: white;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
background-color: $grey-80;
|
||||
|
||||
&.btn-small {
|
||||
padding: $unit $unit-2x;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + $unit);
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-width: 150px;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
padding: $unit-2x;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.2s ease;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-10;
|
||||
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $grey-90;
|
||||
}
|
||||
}
|
||||
|
||||
.post-composer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $unit-4x;
|
||||
|
||||
&:has(.metadata-sidebar) {
|
||||
grid-template-columns: 1fr 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
color: $grey-10;
|
||||
background: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $grey-60;
|
||||
}
|
||||
}
|
||||
|
||||
.link-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.link-url-input {
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 8px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 1.125rem;
|
||||
background-color: $grey-95;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-50;
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.link-description {
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 8px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
background-color: $grey-95;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-50;
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-upload,
|
||||
.album-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.photo-placeholder,
|
||||
.album-placeholder {
|
||||
border: 2px dashed $grey-80;
|
||||
border-radius: 12px;
|
||||
padding: $unit-8x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: $grey-95;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-60;
|
||||
background: $grey-90;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: $grey-50;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: $grey-30;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.album-hint {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-50;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.metadata-sidebar {
|
||||
background: $grey-95;
|
||||
border-radius: 12px;
|
||||
padding: $unit-3x;
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: $unit-3x;
|
||||
|
||||
h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
> * + * {
|
||||
margin-top: $unit-3x;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: $unit-2x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 8px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
background-color: white;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-50;
|
||||
}
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: $unit $unit-2x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 8px;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
background-color: white;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-50;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-input-wrapper {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $unit;
|
||||
margin-top: $unit;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px $unit-2x;
|
||||
background: $grey-80;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-60;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
.post-composer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metadata-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import Page from '$lib/components/Page.svelte'
|
||||
import DataTable from '$lib/components/admin/DataTable.svelte'
|
||||
import ProjectTitleCell from '$lib/components/admin/ProjectTitleCell.svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import ProjectListItem from '$lib/components/admin/ProjectListItem.svelte'
|
||||
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||
|
||||
interface Project {
|
||||
id: number
|
||||
|
|
@ -15,45 +15,30 @@
|
|||
backgroundColor: string | null
|
||||
highlightColor: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
let projects = $state<Project[]>([])
|
||||
let isLoading = $state(true)
|
||||
let error = $state('')
|
||||
let total = $state(0)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
width: '40%',
|
||||
component: ProjectTitleCell
|
||||
},
|
||||
{
|
||||
key: 'year',
|
||||
label: 'Year',
|
||||
width: '15%'
|
||||
},
|
||||
{
|
||||
key: 'client',
|
||||
label: 'Client',
|
||||
width: '30%',
|
||||
render: (project: Project) => project.client || '—'
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
width: '15%',
|
||||
render: (project: Project) => {
|
||||
return project.status === 'published' ? '🟢' : '⚪'
|
||||
}
|
||||
}
|
||||
]
|
||||
let showDeleteModal = $state(false)
|
||||
let projectToDelete = $state<Project | null>(null)
|
||||
let activeDropdown = $state<number | null>(null)
|
||||
|
||||
onMount(async () => {
|
||||
await loadProjects()
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', handleOutsideClick)
|
||||
return () => document.removeEventListener('click', handleOutsideClick)
|
||||
})
|
||||
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.dropdown-container')) {
|
||||
activeDropdown = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
|
|
@ -76,7 +61,6 @@
|
|||
|
||||
const data = await response.json()
|
||||
projects = data.projects
|
||||
total = data.pagination?.total || projects.length
|
||||
} catch (err) {
|
||||
error = 'Failed to load projects'
|
||||
console.error(err)
|
||||
|
|
@ -85,12 +69,79 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleRowClick(project: Project) {
|
||||
goto(`/admin/projects/${project.id}/edit`)
|
||||
function handleToggleDropdown(event: CustomEvent<{ projectId: number; event: MouseEvent }>) {
|
||||
event.detail.event.stopPropagation()
|
||||
activeDropdown = activeDropdown === event.detail.projectId ? null : event.detail.projectId
|
||||
}
|
||||
|
||||
function handleEdit(event: CustomEvent<{ project: Project; event: MouseEvent }>) {
|
||||
event.detail.event.stopPropagation()
|
||||
goto(`/admin/projects/${event.detail.project.id}/edit`)
|
||||
}
|
||||
|
||||
async function handleTogglePublish(event: CustomEvent<{ project: Project; event: MouseEvent }>) {
|
||||
event.detail.event.stopPropagation()
|
||||
activeDropdown = null
|
||||
|
||||
const project = event.detail.project
|
||||
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
const newStatus = project.status === 'published' ? 'draft' : 'published'
|
||||
|
||||
const response = await fetch(`/api/projects/${project.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${auth}`
|
||||
},
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await loadProjects()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update project status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(event: CustomEvent<{ project: Project; event: MouseEvent }>) {
|
||||
event.detail.event.stopPropagation()
|
||||
activeDropdown = null
|
||||
projectToDelete = event.detail.project
|
||||
showDeleteModal = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!projectToDelete) return
|
||||
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
|
||||
const response = await fetch(`/api/projects/${projectToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await loadProjects()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete project:', err)
|
||||
} finally {
|
||||
showDeleteModal = false
|
||||
projectToDelete = null
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
showDeleteModal = false
|
||||
projectToDelete = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<h1>Projects</h1>
|
||||
<div class="header-actions">
|
||||
|
|
@ -100,23 +151,39 @@
|
|||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{:else}
|
||||
<div class="projects-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{total}</span>
|
||||
<span class="stat-label">Total projects</span>
|
||||
</div>
|
||||
{:else if isLoading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading projects...</p>
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No projects found. Create your first project!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="projects-list">
|
||||
{#each projects as project}
|
||||
<ProjectListItem
|
||||
{project}
|
||||
isDropdownActive={activeDropdown === project.id}
|
||||
ontoggleDropdown={handleToggleDropdown}
|
||||
onedit={handleEdit}
|
||||
ontogglePublish={handleTogglePublish}
|
||||
ondelete={handleDelete}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={projects}
|
||||
{columns}
|
||||
loading={isLoading}
|
||||
emptyMessage="No projects found. Create your first project!"
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
{/if}
|
||||
</Page>
|
||||
</AdminPage>
|
||||
|
||||
{#if showDeleteModal && projectToDelete}
|
||||
<DeleteConfirmationModal
|
||||
title="Delete project?"
|
||||
message={`Are you sure you want to delete "${projectToDelete.title}"? This action cannot be undone.`}
|
||||
onconfirm={confirmDelete}
|
||||
oncancel={cancelDelete}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
|
|
@ -146,6 +213,8 @@
|
|||
font-size: 0.925rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&.btn-primary {
|
||||
background-color: $grey-10;
|
||||
|
|
@ -164,28 +233,46 @@
|
|||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.projects-stats {
|
||||
display: flex;
|
||||
gap: $unit-4x;
|
||||
margin-bottom: $unit-4x;
|
||||
.loading {
|
||||
padding: $unit-8x;
|
||||
text-align: center;
|
||||
color: $grey-40;
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid $grey-80;
|
||||
border-top-color: $primary-color;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto $unit-2x;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: $unit-8x;
|
||||
text-align: center;
|
||||
color: $grey-40;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.projects-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,91 +2,15 @@
|
|||
import { onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { z } from 'zod'
|
||||
import AdminSegmentedControl from '$lib/components/admin/AdminSegmentedControl.svelte'
|
||||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
||||
import Editor from '$lib/components/admin/Editor.svelte'
|
||||
import ProjectForm from '$lib/components/admin/ProjectForm.svelte'
|
||||
import type { Project } from '$lib/types/project'
|
||||
|
||||
// Zod schema for project validation
|
||||
const projectSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().optional(),
|
||||
year: z
|
||||
.number()
|
||||
.min(1990)
|
||||
.max(new Date().getFullYear() + 1),
|
||||
client: z.string().optional(),
|
||||
externalUrl: z.string().url().optional().or(z.literal('')),
|
||||
backgroundColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-Fa-f]{6}$/)
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
highlightColor: z
|
||||
.string()
|
||||
.regex(/^#[0-9A-Fa-f]{6}$/)
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
status: z.enum(['draft', 'published'])
|
||||
})
|
||||
|
||||
interface Project {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
subtitle: string | null
|
||||
description: string | null
|
||||
year: number
|
||||
client: string | null
|
||||
role: string | null
|
||||
technologies: string[] | null
|
||||
featuredImage: string | null
|
||||
gallery: any[] | null
|
||||
externalUrl: string | null
|
||||
caseStudyContent: any | null
|
||||
backgroundColor: string | null
|
||||
highlightColor: string | null
|
||||
displayOrder: number
|
||||
status: string
|
||||
}
|
||||
|
||||
// State
|
||||
let project = $state<Project | null>(null)
|
||||
let isLoading = $state(true)
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
let successMessage = $state('')
|
||||
let activeTab = $state('metadata')
|
||||
let validationErrors = $state<Record<string, string>>({})
|
||||
let showPublishMenu = $state(false)
|
||||
|
||||
// Form fields as individual state variables for reactivity
|
||||
let title = $state('')
|
||||
let subtitle = $state('') // Hidden but kept for backward compatibility
|
||||
let description = $state('')
|
||||
let year = $state(new Date().getFullYear())
|
||||
let client = $state('')
|
||||
let role = $state('') // Hidden but kept for backward compatibility
|
||||
let technologies = $state('') // Hidden but kept for backward compatibility
|
||||
let externalUrl = $state('')
|
||||
let backgroundColor = $state('')
|
||||
let highlightColor = $state('')
|
||||
let status = $state<'draft' | 'published'>('draft')
|
||||
let caseStudyContent = $state<any>({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }]
|
||||
})
|
||||
|
||||
// Ref to the editor component
|
||||
let editorRef: any
|
||||
|
||||
const projectId = $derived($page.params.id)
|
||||
|
||||
const tabOptions = [
|
||||
{ value: 'metadata', label: 'Metadata' },
|
||||
{ value: 'case-study', label: 'Case Study' }
|
||||
]
|
||||
|
||||
onMount(async () => {
|
||||
await loadProject()
|
||||
})
|
||||
|
|
@ -109,23 +33,6 @@
|
|||
|
||||
const data = await response.json()
|
||||
project = data
|
||||
|
||||
// Populate form fields
|
||||
title = data.title || ''
|
||||
subtitle = data.subtitle || ''
|
||||
description = data.description || ''
|
||||
year = data.year || new Date().getFullYear()
|
||||
client = data.client || ''
|
||||
role = data.role || ''
|
||||
technologies = Array.isArray(data.technologies) ? data.technologies.join(', ') : ''
|
||||
externalUrl = data.externalUrl || ''
|
||||
backgroundColor = data.backgroundColor || ''
|
||||
highlightColor = data.highlightColor || ''
|
||||
status = data.status || 'draft'
|
||||
|
||||
if (data.caseStudyContent) {
|
||||
caseStudyContent = data.caseStudyContent
|
||||
}
|
||||
} catch (err) {
|
||||
error = 'Failed to load project'
|
||||
console.error(err)
|
||||
|
|
@ -133,521 +40,19 @@
|
|||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
try {
|
||||
projectSchema.parse({
|
||||
title,
|
||||
description: description || undefined,
|
||||
year,
|
||||
client: client || undefined,
|
||||
externalUrl: externalUrl || undefined,
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
highlightColor: highlightColor || undefined,
|
||||
status
|
||||
})
|
||||
validationErrors = {}
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const errors: Record<string, string> = {}
|
||||
err.errors.forEach((e) => {
|
||||
if (e.path[0]) {
|
||||
errors[e.path[0].toString()] = e.message
|
||||
}
|
||||
})
|
||||
validationErrors = errors
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditorChange(content: any) {
|
||||
caseStudyContent = content
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// Check if we're on the case study tab and should save editor content
|
||||
if (activeTab === 'case-study' && editorRef) {
|
||||
const editorData = await editorRef.save()
|
||||
if (editorData) {
|
||||
caseStudyContent = editorData
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateForm()) {
|
||||
error = 'Please fix the validation errors'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSaving = true
|
||||
error = ''
|
||||
successMessage = ''
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
year,
|
||||
client,
|
||||
role,
|
||||
technologies: technologies
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
externalUrl,
|
||||
backgroundColor,
|
||||
highlightColor,
|
||||
status,
|
||||
caseStudyContent:
|
||||
caseStudyContent && caseStudyContent.content && caseStudyContent.content.length > 0
|
||||
? caseStudyContent
|
||||
: null
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save project')
|
||||
}
|
||||
|
||||
successMessage = 'Project saved successfully!'
|
||||
|
||||
setTimeout(() => {
|
||||
successMessage = ''
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error = 'Failed to save project'
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
status = 'published'
|
||||
await handleSave()
|
||||
|
||||
if (!error) {
|
||||
await loadProject()
|
||||
}
|
||||
showPublishMenu = false
|
||||
}
|
||||
|
||||
async function handleUnpublish() {
|
||||
status = 'draft'
|
||||
await handleSave()
|
||||
|
||||
if (!error) {
|
||||
await loadProject()
|
||||
}
|
||||
showPublishMenu = false
|
||||
}
|
||||
|
||||
function togglePublishMenu() {
|
||||
showPublishMenu = !showPublishMenu
|
||||
}
|
||||
|
||||
// Close menu when clicking outside
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.save-actions')) {
|
||||
showPublishMenu = false
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unsaved changes
|
||||
async function checkForUnsavedChanges() {
|
||||
if (editorRef && typeof editorRef.getIsDirty === 'function') {
|
||||
const isDirty = editorRef.getIsDirty()
|
||||
if (isDirty) {
|
||||
return confirm('You have unsaved changes. Are you sure you want to leave?')
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showPublishMenu) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="admin-container">
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading project...</div>
|
||||
{:else if !project}
|
||||
<div class="error">Project not found</div>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="success-message">{successMessage}</div>
|
||||
{/if}
|
||||
|
||||
<div class="tab-panels">
|
||||
<!-- Metadata Panel -->
|
||||
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
|
||||
<div class="controls-bar">
|
||||
<AdminSegmentedControl
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={(value) => (activeTab = value)}
|
||||
/>
|
||||
<div class="save-actions">
|
||||
<button onclick={handleSave} disabled={isSaving} class="btn btn-primary save-button">
|
||||
{isSaving ? 'Saving...' : status === 'published' ? 'Save' : 'Save Draft'}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary chevron-button"
|
||||
class:active={showPublishMenu}
|
||||
onclick={togglePublishMenu}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if showPublishMenu}
|
||||
<div class="publish-menu">
|
||||
{#if status === 'published'}
|
||||
<button onclick={handleUnpublish} class="menu-item"> Unpublish </button>
|
||||
{:else}
|
||||
<button onclick={handlePublish} class="menu-item"> Publish </button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-content">
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}}
|
||||
>
|
||||
<div class="form-section">
|
||||
<FormFieldWrapper label="Title" required error={validationErrors.title}>
|
||||
<input type="text" bind:value={title} required placeholder="Project title" />
|
||||
</FormFieldWrapper>
|
||||
|
||||
<FormFieldWrapper label="Description" error={validationErrors.description}>
|
||||
<textarea
|
||||
bind:value={description}
|
||||
rows="3"
|
||||
placeholder="Short description for project cards"
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<div class="form-row">
|
||||
<FormFieldWrapper label="Year" required error={validationErrors.year}>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={year}
|
||||
required
|
||||
min="1990"
|
||||
max={new Date().getFullYear() + 1}
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<FormFieldWrapper label="Client" error={validationErrors.client}>
|
||||
<input type="text" bind:value={client} placeholder="Client or company name" />
|
||||
</FormFieldWrapper>
|
||||
</div>
|
||||
|
||||
<FormFieldWrapper label="External URL" error={validationErrors.externalUrl}>
|
||||
<input type="url" bind:value={externalUrl} placeholder="https://example.com" />
|
||||
</FormFieldWrapper>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Styling</h2>
|
||||
|
||||
<div class="form-row">
|
||||
<FormFieldWrapper
|
||||
label="Background Color"
|
||||
helpText="Hex color for project card"
|
||||
error={validationErrors.backgroundColor}
|
||||
>
|
||||
<div class="color-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={backgroundColor}
|
||||
placeholder="#FFFFFF"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
/>
|
||||
{#if backgroundColor}
|
||||
<div class="color-preview" style="background-color: {backgroundColor}"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<FormFieldWrapper
|
||||
label="Highlight Color"
|
||||
helpText="Accent color for the project"
|
||||
error={validationErrors.highlightColor}
|
||||
>
|
||||
<div class="color-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={highlightColor}
|
||||
placeholder="#000000"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
/>
|
||||
{#if highlightColor}
|
||||
<div class="color-preview" style="background-color: {highlightColor}"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Case Study Panel -->
|
||||
<div class="panel case-study-wrapper" class:active={activeTab === 'case-study'}>
|
||||
<div class="controls-bar">
|
||||
<AdminSegmentedControl
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={(value) => (activeTab = value)}
|
||||
/>
|
||||
<div class="save-actions">
|
||||
<button onclick={handleSave} disabled={isSaving} class="btn btn-primary save-button">
|
||||
{isSaving ? 'Saving...' : status === 'published' ? 'Save' : 'Save Draft'}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary chevron-button"
|
||||
class:active={showPublishMenu}
|
||||
onclick={togglePublishMenu}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 4.5L6 7.5L9 4.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if showPublishMenu}
|
||||
<div class="publish-menu">
|
||||
{#if status === 'published'}
|
||||
<button onclick={handleUnpublish} class="menu-item"> Unpublish </button>
|
||||
{:else}
|
||||
<button onclick={handlePublish} class="menu-item"> Publish </button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content">
|
||||
<Editor
|
||||
bind:this={editorRef}
|
||||
bind:data={caseStudyContent}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Write your case study here..."
|
||||
minHeight={400}
|
||||
autofocus={false}
|
||||
class="case-study-editor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading project...</div>
|
||||
{:else if error}
|
||||
<div class="error">{error}</div>
|
||||
{:else if !project}
|
||||
<div class="error">Project not found</div>
|
||||
{:else}
|
||||
<ProjectForm {project} mode="edit" />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.admin-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 $unit-4x $unit-4x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: 0 $unit-2x $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
position: relative;
|
||||
padding: 0 $unit-4x $unit-2x;
|
||||
border-bottom: 1px solid $grey-80;
|
||||
margin-bottom: 0;
|
||||
margin-top: -$unit-2x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: 0 $unit-3x $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.save-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit $unit-3x;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background-color: $grey-10;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-secondary {
|
||||
background-color: $grey-95;
|
||||
color: $grey-20;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $grey-90;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save-button {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
padding-right: $unit-2x;
|
||||
}
|
||||
|
||||
.chevron-button {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding: $unit $unit;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
&.active {
|
||||
background-color: $grey-20;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&.active svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.publish-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: $unit;
|
||||
background: white;
|
||||
border-radius: $unit;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
min-width: 120px;
|
||||
z-index: 100;
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.925rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
color: $grey-10;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-95;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-panels {
|
||||
position: relative;
|
||||
|
||||
.panel {
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
background: white;
|
||||
border-radius: $unit-2x;
|
||||
padding: $unit-4x 0;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
|
|
@ -659,143 +64,4 @@
|
|||
.error {
|
||||
color: #d33;
|
||||
}
|
||||
|
||||
.error-message,
|
||||
.success-message {
|
||||
padding: $unit-3x;
|
||||
border-radius: $unit;
|
||||
margin-bottom: $unit-4x;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
max-width: 700px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
color: #d33;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #efe;
|
||||
color: #363;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: $unit-6x;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $unit-2x;
|
||||
padding-bottom: $unit-3x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
:global(.form-field) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='url'],
|
||||
input[type='number'],
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: calc($unit * 1.5);
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $unit;
|
||||
font-size: 1rem;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
transition: border-color 0.2s ease;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-40;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.color-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: $unit;
|
||||
border: 1px solid $grey-80;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.case-study-wrapper {
|
||||
background: white;
|
||||
border-radius: $unit-2x;
|
||||
padding: $unit-4x 0 0;
|
||||
min-height: 80vh;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x 0 0;
|
||||
height: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-content {
|
||||
padding: $unit-4x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
/* The editor component will handle its own padding and scrolling */
|
||||
:global(.case-study-editor) {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
5
src/routes/admin/projects/new/+page.svelte
Normal file
5
src/routes/admin/projects/new/+page.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import ProjectForm from '$lib/components/admin/ProjectForm.svelte'
|
||||
</script>
|
||||
|
||||
<ProjectForm mode="create" />
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Page from '$lib/components/Page.svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Editor from '$lib/components/admin/Editor.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<h1>Upload Test</h1>
|
||||
<a href="/admin/projects" class="back-link">← Back to Projects</a>
|
||||
|
|
@ -179,7 +179,7 @@
|
|||
<pre>{JSON.stringify(testContent, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
role: body.role,
|
||||
technologies: body.technologies || [],
|
||||
featuredImage: body.featuredImage,
|
||||
logoUrl: body.logoUrl,
|
||||
gallery: body.gallery || [],
|
||||
externalUrl: body.externalUrl,
|
||||
caseStudyContent: body.caseStudyContent,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export const PUT: RequestHandler = async (event) => {
|
|||
role: body.role ?? existing.role,
|
||||
technologies: body.technologies ?? existing.technologies,
|
||||
featuredImage: body.featuredImage ?? existing.featuredImage,
|
||||
logoUrl: body.logoUrl ?? existing.logoUrl,
|
||||
gallery: body.gallery ?? existing.gallery,
|
||||
externalUrl: body.externalUrl ?? existing.externalUrl,
|
||||
caseStudyContent: body.caseStudyContent ?? existing.caseStudyContent,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const config = {
|
|||
$illos: 'src/assets/illos',
|
||||
$components: 'src/lib/components',
|
||||
$utils: 'src/lib/utils',
|
||||
$styles: 'src/styles'
|
||||
$styles: 'src/assets/styles'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import autoprefixer from 'autoprefixer'
|
|||
import svg from '@poppanator/sveltekit-svg'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
watch: {
|
||||
usePolling: true
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
svg({
|
||||
|
|
@ -59,7 +64,8 @@ export default defineConfig({
|
|||
@import './src/assets/styles/fonts.scss';
|
||||
@import './src/assets/styles/themes.scss';
|
||||
@import './src/assets/styles/globals.scss';
|
||||
`
|
||||
`,
|
||||
api: 'modern-compiler'
|
||||
}
|
||||
},
|
||||
postcss: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue