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",
|
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.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/eslint": "^8.56.7",
|
||||||
"@types/node": "^22.0.2",
|
"@types/node": "^22.0.2",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
|
|
@ -108,7 +108,6 @@
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
"@jridgewell/trace-mapping": "^0.3.24"
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
|
@ -134,7 +133,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
|
|
@ -150,7 +148,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -166,7 +163,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -182,7 +178,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
|
|
@ -198,7 +193,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
|
@ -214,7 +208,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
|
|
@ -230,7 +223,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
|
@ -246,7 +238,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
|
|
@ -262,7 +253,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -278,7 +268,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -294,7 +283,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -310,7 +298,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -326,7 +313,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -342,7 +328,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -358,7 +343,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -374,7 +358,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -390,7 +373,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
|
|
@ -423,7 +405,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
|
|
@ -456,7 +437,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
|
|
@ -472,7 +452,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
|
|
@ -488,7 +467,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -504,7 +482,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -520,7 +497,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
|
|
@ -1105,7 +1081,6 @@
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
|
||||||
"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
|
"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/set-array": "^1.2.1",
|
"@jridgewell/set-array": "^1.2.1",
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.10",
|
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||||
|
|
@ -1119,7 +1094,6 @@
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1128,7 +1102,6 @@
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
|
||||||
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1143,7 +1116,6 @@
|
||||||
"version": "0.3.25",
|
"version": "0.3.25",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
|
||||||
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
|
@ -1205,8 +1177,7 @@
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.25",
|
"version": "1.0.0-next.25",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz",
|
||||||
"integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==",
|
"integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@poppanator/sveltekit-svg": {
|
"node_modules/@poppanator/sveltekit-svg": {
|
||||||
"version": "5.0.0-svelte5.4",
|
"version": "5.0.0-svelte5.4",
|
||||||
|
|
@ -1702,6 +1673,15 @@
|
||||||
"win32"
|
"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": {
|
"node_modules/@sveltejs/adapter-auto": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.2.2.tgz",
|
||||||
|
|
@ -1732,7 +1712,6 @@
|
||||||
"version": "2.5.18",
|
"version": "2.5.18",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.18.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.18.tgz",
|
||||||
"integrity": "sha512-+g06hvpVAnH7b4CDjhnTDgFWBKBiQJpuSmQeGYOuzbO3SC3tdYjRNlDCrafvDtKbGiT2uxY5Dn9qdEUGVZdWOQ==",
|
"integrity": "sha512-+g06hvpVAnH7b4CDjhnTDgFWBKBiQJpuSmQeGYOuzbO3SC3tdYjRNlDCrafvDtKbGiT2uxY5Dn9qdEUGVZdWOQ==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
|
|
@ -1761,59 +1740,43 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/vite-plugin-svelte": {
|
"node_modules/@sveltejs/vite-plugin-svelte": {
|
||||||
"version": "3.1.2",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz",
|
||||||
"integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",
|
"integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
|
"@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.7",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"kleur": "^4.1.5",
|
"kleur": "^4.1.5",
|
||||||
"magic-string": "^0.30.10",
|
"magic-string": "^0.30.12",
|
||||||
"svelte-hmr": "^0.16.0",
|
"vitefu": "^1.0.3"
|
||||||
"vitefu": "^0.2.5"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || >=20"
|
"node": "^18.0.0 || ^20.0.0 || >=22"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"svelte": "^4.0.0 || ^5.0.0-next.0",
|
"svelte": "^5.0.0-next.96 || ^5.0.0",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
|
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
|
||||||
"version": "2.1.0",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz",
|
||||||
"integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
|
"integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.3.4"
|
"debug": "^4.3.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || >=20"
|
"node": "^18.0.0 || ^20.0.0 || >=22"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0",
|
||||||
"svelte": "^4.0.0 || ^5.0.0-next.0",
|
"svelte": "^5.0.0-next.96 || ^5.0.0",
|
||||||
"vite": "^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": {
|
"node_modules/@tiptap/core": {
|
||||||
"version": "2.12.0",
|
"version": "2.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.12.0.tgz",
|
||||||
|
|
@ -2455,8 +2418,7 @@
|
||||||
"node_modules/@types/cookie": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/eslint": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "8.56.10",
|
"version": "8.56.10",
|
||||||
|
|
@ -2847,10 +2809,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.12.0",
|
"version": "8.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||||
"integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==",
|
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -2867,15 +2829,6 @@
|
||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
|
|
@ -2917,7 +2870,7 @@
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"normalize-path": "^3.0.0",
|
"normalize-path": "^3.0.0",
|
||||||
"picomatch": "^2.0.4"
|
"picomatch": "^2.0.4"
|
||||||
|
|
@ -2938,12 +2891,12 @@
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||||
},
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||||
"dev": true,
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"engines": {
|
||||||
"dequal": "^2.0.3"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/array-union": {
|
"node_modules/array-union": {
|
||||||
|
|
@ -3032,12 +2985,12 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.0.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
"integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==",
|
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||||
"dev": true,
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"engines": {
|
||||||
"dequal": "^2.0.3"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
|
|
@ -3058,7 +3011,7 @@
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
},
|
},
|
||||||
|
|
@ -3086,7 +3039,7 @@
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.1.1"
|
||||||
},
|
},
|
||||||
|
|
@ -3218,7 +3171,7 @@
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"anymatch": "~3.1.2",
|
"anymatch": "~3.1.2",
|
||||||
"braces": "~3.0.2",
|
"braces": "~3.0.2",
|
||||||
|
|
@ -3242,7 +3195,7 @@
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-glob": "^4.0.1"
|
"is-glob": "^4.0.1"
|
||||||
},
|
},
|
||||||
|
|
@ -3263,6 +3216,15 @@
|
||||||
"node": ">=9"
|
"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": {
|
"node_modules/cluster-key-slot": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
|
@ -3362,7 +3324,6 @@
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
|
|
@ -3576,8 +3537,7 @@
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.0.0.tgz",
|
||||||
"integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA==",
|
"integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/devlop": {
|
"node_modules/devlop": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
|
|
@ -3718,7 +3678,6 @@
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
|
|
@ -3913,10 +3872,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esm-env": {
|
"node_modules/esm-env": {
|
||||||
"version": "1.0.0",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||||
"integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==",
|
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||||
"dev": true
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/espree": {
|
"node_modules/espree": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
|
|
@ -3961,13 +3920,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esrap": {
|
"node_modules/esrap": {
|
||||||
"version": "1.2.2",
|
"version": "1.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz",
|
||||||
"integrity": "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==",
|
"integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||||
"@types/estree": "^1.0.1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esrecurse": {
|
"node_modules/esrecurse": {
|
||||||
|
|
@ -4130,7 +4088,7 @@
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
},
|
},
|
||||||
|
|
@ -4351,8 +4309,7 @@
|
||||||
"node_modules/globalyzer": {
|
"node_modules/globalyzer": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
|
||||||
"integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==",
|
"integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/globby": {
|
"node_modules/globby": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
|
|
@ -4377,8 +4334,7 @@
|
||||||
"node_modules/globrex": {
|
"node_modules/globrex": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
|
|
@ -4509,7 +4465,7 @@
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz",
|
||||||
"integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==",
|
"integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==",
|
||||||
"dev": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
|
|
@ -4531,7 +4487,6 @@
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
||||||
"integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==",
|
"integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==",
|
||||||
"dev": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
|
@ -4596,7 +4551,7 @@
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"binary-extensions": "^2.0.0"
|
"binary-extensions": "^2.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -4645,7 +4600,7 @@
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -4662,7 +4617,7 @@
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
},
|
},
|
||||||
|
|
@ -4679,7 +4634,7 @@
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
|
|
@ -4694,14 +4649,20 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-reference": {
|
"node_modules/is-reference": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||||
"integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==",
|
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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": {
|
"node_modules/is-typedarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
|
||||||
|
|
@ -4887,7 +4848,6 @@
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||||
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
|
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
|
|
@ -4938,8 +4898,7 @@
|
||||||
"node_modules/locate-character": {
|
"node_modules/locate-character": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
|
|
@ -5168,7 +5127,6 @@
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||||
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
|
|
@ -5177,7 +5135,6 @@
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
|
||||||
"integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==",
|
"integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
|
|
@ -5210,7 +5167,6 @@
|
||||||
"version": "3.3.7",
|
"version": "3.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -5286,7 +5242,7 @@
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5497,7 +5453,6 @@
|
||||||
"version": "8.4.39",
|
"version": "8.4.39",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
|
||||||
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
|
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|
@ -5967,7 +5922,7 @@
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"picomatch": "^2.2.1"
|
"picomatch": "^2.2.1"
|
||||||
},
|
},
|
||||||
|
|
@ -6170,7 +6125,6 @@
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||||
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
|
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mri": "^1.1.0"
|
"mri": "^1.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -6220,7 +6174,7 @@
|
||||||
"version": "1.77.8",
|
"version": "1.77.8",
|
||||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz",
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz",
|
||||||
"integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==",
|
"integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": ">=3.0.0 <4.0.0",
|
"chokidar": ">=3.0.0 <4.0.0",
|
||||||
"immutable": "^4.0.0",
|
"immutable": "^4.0.0",
|
||||||
|
|
@ -6261,8 +6215,7 @@
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
|
||||||
"integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==",
|
"integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.34.2",
|
"version": "0.34.2",
|
||||||
|
|
@ -6348,7 +6301,6 @@
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
|
||||||
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
|
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@polka/url": "^1.0.0-next.24",
|
"@polka/url": "^1.0.0-next.24",
|
||||||
"mrmime": "^2.0.0",
|
"mrmime": "^2.0.0",
|
||||||
|
|
@ -6609,23 +6561,24 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/svelte": {
|
"node_modules/svelte": {
|
||||||
"version": "5.0.0-next.169",
|
"version": "5.33.6",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.0.0-next.169.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.33.6.tgz",
|
||||||
"integrity": "sha512-8VD4/adoVW5Oo4Kub+jnWnuexAtskjbLuAhy3IGMkSKcQ6RVKEQPo4j+8Xr58epow6ccA7S5zp+PsiTv4eW5Zg==",
|
"integrity": "sha512-bxg2QY03JlrilCZmDlshY95Argj0rnX43UQFWZN4fct8PZTNBBmvfow2A6yOW1+YweDjhC2qdZF66ASI0Y21Tw==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.1",
|
"@ampproject/remapping": "^2.3.0",
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
|
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||||
"@types/estree": "^1.0.5",
|
"@types/estree": "^1.0.5",
|
||||||
"acorn": "^8.11.3",
|
"acorn": "^8.12.1",
|
||||||
"acorn-typescript": "^1.4.13",
|
"aria-query": "^5.3.1",
|
||||||
"aria-query": "^5.3.0",
|
"axobject-query": "^4.1.0",
|
||||||
"axobject-query": "^4.0.0",
|
"clsx": "^2.1.1",
|
||||||
"esm-env": "^1.0.0",
|
"esm-env": "^1.2.1",
|
||||||
"esrap": "^1.2.2",
|
"esrap": "^1.4.6",
|
||||||
"is-reference": "^3.0.2",
|
"is-reference": "^3.0.3",
|
||||||
"locate-character": "^3.0.0",
|
"locate-character": "^3.0.0",
|
||||||
"magic-string": "^0.30.5",
|
"magic-string": "^0.30.11",
|
||||||
"zimmerframe": "^1.1.2"
|
"zimmerframe": "^1.1.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -6840,7 +6793,6 @@
|
||||||
"version": "0.2.9",
|
"version": "0.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
||||||
"integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==",
|
"integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"globalyzer": "0.1.0",
|
"globalyzer": "0.1.0",
|
||||||
"globrex": "^0.1.2"
|
"globrex": "^0.1.2"
|
||||||
|
|
@ -6917,7 +6869,7 @@
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -6929,7 +6881,6 @@
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
|
|
@ -7477,7 +7428,7 @@
|
||||||
"version": "5.5.3",
|
"version": "5.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
|
||||||
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
|
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -7598,7 +7549,6 @@
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.2.tgz",
|
||||||
"integrity": "sha512-6lA7OBHBlXUxiJxbO5aAY2fsHHzDr1q7DvXYnyZycRs2Dz+dXBWuhpWHvmljTRTpQC2uvGmUFFkSHF2vGo90MA==",
|
"integrity": "sha512-6lA7OBHBlXUxiJxbO5aAY2fsHHzDr1q7DvXYnyZycRs2Dz+dXBWuhpWHvmljTRTpQC2uvGmUFFkSHF2vGo90MA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
|
|
@ -7650,13 +7600,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitefu": {
|
"node_modules/vitefu": {
|
||||||
"version": "0.2.5",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.6.tgz",
|
||||||
"integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
|
"integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"tests/deps/*",
|
||||||
|
"tests/projects/*"
|
||||||
|
],
|
||||||
"peerDependencies": {
|
"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": {
|
"peerDependenciesMeta": {
|
||||||
"vite": {
|
"vite": {
|
||||||
|
|
@ -7850,8 +7803,7 @@
|
||||||
"node_modules/zimmerframe": {
|
"node_modules/zimmerframe": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
|
||||||
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
|
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.30",
|
"version": "3.25.30",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
|
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.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/eslint": "^8.56.7",
|
||||||
"@types/node": "^22.0.2",
|
"@types/node": "^22.0.2",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
|
|
@ -101,5 +101,8 @@
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"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)
|
role String? @db.VarChar(255)
|
||||||
technologies Json? // Array of tech stack
|
technologies Json? // Array of tech stack
|
||||||
featuredImage String? @db.VarChar(500)
|
featuredImage String? @db.VarChar(500)
|
||||||
|
logoUrl String? @db.VarChar(500)
|
||||||
gallery Json? // Array of image URLs
|
gallery Json? // Array of image URLs
|
||||||
externalUrl String? @db.VarChar(500)
|
externalUrl String? @db.VarChar(500)
|
||||||
caseStudyContent Json? // BlockNote JSON format
|
caseStudyContent Json? // BlockNote JSON format
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ $red-60: #e33d3d;
|
||||||
$red-40: #d31919;
|
$red-40: #d31919;
|
||||||
$red-00: #3d0c0c;
|
$red-00: #3d0c0c;
|
||||||
|
|
||||||
|
$salmon-pink: #ffbdb3; // Desaturated salmon pink for hover states
|
||||||
|
|
||||||
$bg-color: #e8e8e8;
|
$bg-color: #e8e8e8;
|
||||||
$page-color: #ffffff;
|
$page-color: #ffffff;
|
||||||
$card-color: #f7f7f7;
|
$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
|
isLoading?: boolean
|
||||||
emptyMessage?: string
|
emptyMessage?: string
|
||||||
onRowClick?: (item: T) => void
|
onRowClick?: (item: T) => void
|
||||||
|
unstyled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -20,7 +21,8 @@
|
||||||
columns = [],
|
columns = [],
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
emptyMessage = 'No data found',
|
emptyMessage = 'No data found',
|
||||||
onRowClick
|
onRowClick,
|
||||||
|
unstyled = false
|
||||||
}: Props<any> = $props()
|
}: Props<any> = $props()
|
||||||
|
|
||||||
function getCellValue(item: any, column: Column<any>) {
|
function getCellValue(item: any, column: Column<any>) {
|
||||||
|
|
@ -39,7 +41,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="data-table-wrapper">
|
<div class="data-table-wrapper" class:unstyled>
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="loading">
|
<div class="loading">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
|
|
@ -85,6 +87,11 @@
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
&.unstyled {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.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
|
initialized = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = props.editor.getJSON()
|
const json = props.editor.getJSON()
|
||||||
data = json
|
data = json
|
||||||
onChange?.(json)
|
onChange?.(json)
|
||||||
|
|
@ -72,9 +72,9 @@
|
||||||
|
|
||||||
<div class="editor-wrapper {className}" style="--min-height: {minHeight}px">
|
<div class="editor-wrapper {className}" style="--min-height: {minHeight}px">
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<EditorWithUpload
|
<EditorWithUpload
|
||||||
bind:editor
|
bind:editor
|
||||||
content={data}
|
content={data}
|
||||||
{onUpdate}
|
{onUpdate}
|
||||||
editable={!readOnly}
|
editable={!readOnly}
|
||||||
{showToolbar}
|
{showToolbar}
|
||||||
|
|
@ -88,8 +88,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '$lib/../assets/styles/variables.scss';
|
@import '$styles/variables.scss';
|
||||||
@import '$lib/../assets/styles/mixins.scss';
|
@import '$styles/mixins.scss';
|
||||||
|
|
||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -117,16 +117,17 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.editor-content .edra) {
|
:global(.editor-content .edra) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.editor-content .editor-toolbar) {
|
:global(.editor-content .editor-toolbar) {
|
||||||
border-bottom: 1px solid $grey-80;
|
border-radius: $card-corner-radius;
|
||||||
|
box-sizing: border-box;
|
||||||
background: $grey-95;
|
background: $grey-95;
|
||||||
padding: $unit-2x;
|
padding: $unit-2x;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
|
@ -136,20 +137,23 @@
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
// Hide scrollbar but keep functionality
|
// Hide scrollbar but keep functionality
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override Edra toolbar styles
|
// Override Edra toolbar styles
|
||||||
:global(.edra-toolbar) {
|
:global(.edra-toolbar) {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
width: auto;
|
width: auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,8 +163,8 @@
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: $unit-4x;
|
padding: 0 $unit-4x;
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
padding: $unit-3x;
|
padding: $unit-3x;
|
||||||
}
|
}
|
||||||
|
|
@ -286,7 +290,7 @@
|
||||||
color: $grey-50;
|
color: $grey-50;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image styles
|
// Image styles
|
||||||
:global(.edra .ProseMirror img) {
|
:global(.edra .ProseMirror img) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
@ -298,11 +302,11 @@
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.edra-media-placeholder-wrapper) {
|
:global(.edra-media-placeholder-wrapper) {
|
||||||
margin: $unit-2x 0;
|
margin: $unit-2x 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.edra-media-placeholder-content) {
|
:global(.edra-media-placeholder-content) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -315,51 +319,51 @@
|
||||||
background: $grey-95;
|
background: $grey-95;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: $grey-60;
|
border-color: $grey-60;
|
||||||
background: $grey-90;
|
background: $grey-90;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.edra-media-placeholder-icon) {
|
:global(.edra-media-placeholder-icon) {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
color: $grey-50;
|
color: $grey-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.edra-media-placeholder-text) {
|
:global(.edra-media-placeholder-text) {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: $grey-30;
|
color: $grey-30;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image container styles
|
// Image container styles
|
||||||
:global(.edra-media-container) {
|
:global(.edra-media-container) {
|
||||||
margin: $unit-3x auto;
|
margin: $unit-3x auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&.align-left {
|
&.align-left {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.align-right {
|
&.align-right {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.align-center {
|
&.align-center {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.edra-media-content) {
|
:global(.edra-media-content) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.edra-media-caption) {
|
:global(.edra-media-caption) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: $unit;
|
margin-top: $unit;
|
||||||
|
|
@ -370,11 +374,11 @@
|
||||||
color: $grey-30;
|
color: $grey-30;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
background: $grey-95;
|
background: $grey-95;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: $grey-60;
|
border-color: $grey-60;
|
||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
import { EdraToolbar, EdraBubbleMenu } from '$lib/components/edra/headless/index.js'
|
import { EdraToolbar, EdraBubbleMenu } from '$lib/components/edra/headless/index.js'
|
||||||
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
|
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
|
||||||
import { focusEditor, type EdraProps } from '$lib/components/edra/utils.js'
|
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 all the same components as Edra
|
||||||
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
|
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
|
||||||
import { all, createLowlight } from 'lowlight'
|
import { all, createLowlight } from 'lowlight'
|
||||||
|
|
@ -32,15 +34,15 @@
|
||||||
import { IFramePlaceholder } from '$lib/components/edra/extensions/iframe/IFramePlaceholder.js'
|
import { IFramePlaceholder } from '$lib/components/edra/extensions/iframe/IFramePlaceholder.js'
|
||||||
import { IFrameExtended } from '$lib/components/edra/extensions/iframe/IFrameExtended.js'
|
import { IFrameExtended } from '$lib/components/edra/extensions/iframe/IFrameExtended.js'
|
||||||
import IFrameExtendedComponent from '$lib/components/edra/headless/components/IFrameExtended.svelte'
|
import IFrameExtendedComponent from '$lib/components/edra/headless/components/IFrameExtended.svelte'
|
||||||
|
|
||||||
// Import Edra styles
|
// Import Edra styles
|
||||||
import '$lib/components/edra/headless/style.css'
|
import '$lib/components/edra/headless/style.css'
|
||||||
import 'katex/dist/katex.min.css'
|
import 'katex/dist/katex.min.css'
|
||||||
import '$lib/components/edra/editor.css'
|
import '$lib/components/edra/editor.css'
|
||||||
import '$lib/components/edra/onedark.css'
|
import '$lib/components/edra/onedark.css'
|
||||||
|
|
||||||
const lowlight = createLowlight(all)
|
const lowlight = createLowlight(all)
|
||||||
|
|
||||||
let {
|
let {
|
||||||
class: className = '',
|
class: className = '',
|
||||||
content = undefined,
|
content = undefined,
|
||||||
|
|
@ -54,50 +56,192 @@
|
||||||
showToolbar = true,
|
showToolbar = true,
|
||||||
placeholder = 'Type "/" for commands...'
|
placeholder = 'Type "/" for commands...'
|
||||||
}: EdraProps & { showToolbar?: boolean; placeholder?: string } = $props()
|
}: EdraProps & { showToolbar?: boolean; placeholder?: string } = $props()
|
||||||
|
|
||||||
let element = $state<HTMLElement>()
|
let element = $state<HTMLElement>()
|
||||||
let isLoading = $state(true)
|
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
|
// Custom image paste handler
|
||||||
function handleImagePaste(view: any, event: ClipboardEvent) {
|
function handleImagePaste(view: any, event: ClipboardEvent) {
|
||||||
const item = event.clipboardData?.items[0]
|
const item = event.clipboardData?.items[0]
|
||||||
|
|
||||||
if (item?.type.indexOf('image') !== 0) {
|
if (item?.type.indexOf('image') !== 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = item.getAsFile()
|
const file = item.getAsFile()
|
||||||
if (!file) return false
|
if (!file) return false
|
||||||
|
|
||||||
// Check file size (2MB max)
|
// Check file size (2MB max)
|
||||||
const filesize = file.size / 1024 / 1024
|
const filesize = file.size / 1024 / 1024
|
||||||
if (filesize > 2) {
|
if (filesize > 2) {
|
||||||
alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`)
|
alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload to our media API
|
// Upload to our media API
|
||||||
uploadImage(file)
|
uploadImage(file)
|
||||||
|
|
||||||
return true // Prevent default paste behavior
|
return true // Prevent default paste behavior
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadImage(file: File) {
|
async function uploadImage(file: File) {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
// Create a placeholder while uploading
|
// Create a placeholder while uploading
|
||||||
const placeholderSrc = URL.createObjectURL(file)
|
const placeholderSrc = URL.createObjectURL(file)
|
||||||
editor.commands.setImage({ src: placeholderSrc })
|
editor.commands.setImage({ src: placeholderSrc })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = localStorage.getItem('admin_auth')
|
const auth = localStorage.getItem('admin_auth')
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
throw new Error('Not authenticated')
|
throw new Error('Not authenticated')
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
||||||
const response = await fetch('/api/media/upload', {
|
const response = await fetch('/api/media/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -105,17 +249,17 @@
|
||||||
},
|
},
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Upload failed')
|
throw new Error('Upload failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
const media = await response.json()
|
const media = await response.json()
|
||||||
|
|
||||||
// Replace placeholder with actual URL
|
// Replace placeholder with actual URL
|
||||||
// Set a reasonable default width (max 600px)
|
// Set a reasonable default width (max 600px)
|
||||||
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
||||||
|
|
||||||
editor.commands.insertContent({
|
editor.commands.insertContent({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
attrs: {
|
attrs: {
|
||||||
|
|
@ -126,7 +270,7 @@
|
||||||
align: 'center'
|
align: 'center'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clean up the object URL
|
// Clean up the object URL
|
||||||
URL.revokeObjectURL(placeholderSrc)
|
URL.revokeObjectURL(placeholderSrc)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -136,7 +280,7 @@
|
||||||
editor.commands.undo()
|
editor.commands.undo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
editor = initiateEditor(
|
editor = initiateEditor(
|
||||||
element,
|
element,
|
||||||
|
|
@ -175,18 +319,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add placeholder
|
// Add placeholder
|
||||||
if (placeholder && editor) {
|
if (placeholder && editor) {
|
||||||
editor.extensionManager.extensions.find(
|
editor.extensionManager.extensions
|
||||||
ext => ext.name === 'placeholder'
|
.find((ext) => ext.name === 'placeholder')
|
||||||
)?.configure({
|
?.configure({
|
||||||
placeholder
|
placeholder
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
return () => editor?.destroy()
|
return () => editor?.destroy()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -194,7 +338,81 @@
|
||||||
<div class={`edra ${className}`}>
|
<div class={`edra ${className}`}>
|
||||||
{#if showToolbar && editor && !isLoading}
|
{#if showToolbar && editor && !isLoading}
|
||||||
<div class="editor-toolbar">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if editor}
|
{#if editor}
|
||||||
|
|
@ -225,6 +443,115 @@
|
||||||
></div>
|
></div>
|
||||||
</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>
|
<style>
|
||||||
.edra {
|
.edra {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -233,10 +560,10 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
border-bottom: 1px solid var(--edra-border-color);
|
|
||||||
background: var(--edra-button-bg-color);
|
background: var(--edra-button-bg-color);
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -248,14 +575,15 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edra-editor {
|
.edra-editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.ProseMirror) {
|
:global(.ProseMirror) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
|
@ -269,4 +597,109 @@
|
||||||
outline: none;
|
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 {
|
.edra-editor {
|
||||||
padding: var(--edra-padding);
|
padding: 0 var(--edra-padding);
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding-left: 2rem;
|
padding-left: 2rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edra-toolbar {
|
.edra-toolbar {
|
||||||
|
|
@ -113,7 +114,7 @@
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
width: var(--edra-separator-width);
|
width: var(--edra-separator-width);
|
||||||
background-color: var(--edra-separator-color);
|
/* background-color: var(--edra-separator-color); */
|
||||||
}
|
}
|
||||||
|
|
||||||
.edra-media-placeholder-wrapper {
|
.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 { page } from '$app/stores'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import AdminSegmentedController from '$lib/components/admin/AdminSegmentedController.svelte'
|
import AdminNavBar from '$lib/components/admin/AdminNavBar.svelte'
|
||||||
|
|
||||||
let { children } = $props()
|
let { children } = $props()
|
||||||
|
|
||||||
|
|
@ -36,9 +36,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Authenticated, show admin layout -->
|
<!-- Authenticated, show admin layout -->
|
||||||
<div class="admin-container">
|
<div class="admin-container">
|
||||||
<header class="admin-header">
|
<AdminNavBar />
|
||||||
<AdminSegmentedController />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="admin-content">
|
<main class="admin-content">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
@ -61,18 +59,12 @@
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: $unit-6x 0 $unit-4x;
|
|
||||||
background-color: $bg-color;
|
background-color: $bg-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-content {
|
.admin-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: $bg-color;
|
padding-top: $unit-4x;
|
||||||
padding-bottom: $unit-6x;
|
padding-bottom: $unit-6x;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,229 +1,29 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import AdminPage from '$lib/components/admin/AdminPage.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<AdminPage>
|
||||||
<header slot="header">
|
<div class="action-grid">
|
||||||
<h1>Dashboard</h1>
|
<a href="/admin/projects/new" class="action-card">
|
||||||
</header>
|
<span class="action-icon">➕</span>
|
||||||
|
<span>New Project</span>
|
||||||
{#if isLoading}
|
</a>
|
||||||
<div class="loading">Loading statistics...</div>
|
<a href="/admin/posts/new" class="action-card">
|
||||||
{:else if error}
|
<span class="action-icon">✏️</span>
|
||||||
<div class="error">{error}</div>
|
<span>Write Post</span>
|
||||||
{:else if stats}
|
</a>
|
||||||
<div class="stats-grid">
|
<a href="/admin/albums/new" class="action-card">
|
||||||
<div class="stat-card">
|
<span class="action-icon">📷</span>
|
||||||
<div class="stat-icon">💼</div>
|
<span>Create Album</span>
|
||||||
<div class="stat-content">
|
</a>
|
||||||
<div class="stat-value">{stats.projects}</div>
|
<a href="/admin/media" class="action-card">
|
||||||
<div class="stat-label">Projects</div>
|
<span class="action-icon">⬆️</span>
|
||||||
</div>
|
<span>Upload Media</span>
|
||||||
<a href="/admin/projects" class="stat-link">View all →</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</AdminPage>
|
||||||
<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>
|
|
||||||
|
|
||||||
<style lang="scss">
|
<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 {
|
.action-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
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'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
let media = $state<Media[]>([])
|
let media = $state<Media[]>([])
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<AdminPage>
|
||||||
<header slot="header">
|
<header slot="header">
|
||||||
<h1>Media Library</h1>
|
<h1>Media Library</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
|
@ -207,7 +207,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</Page>
|
</AdminPage>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
header {
|
header {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { goto } from '$app/navigation'
|
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 DataTable from '$lib/components/admin/DataTable.svelte'
|
||||||
|
import PostDropdown from '$lib/components/admin/PostDropdown.svelte'
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -139,11 +140,11 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<AdminPage>
|
||||||
<header slot="header">
|
<header slot="header">
|
||||||
<h1>Posts</h1>
|
<h1>Posts</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a href="/admin/posts/new" class="btn btn-primary">New Post</a>
|
<PostDropdown />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -171,7 +172,7 @@
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</Page>
|
</AdminPage>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
header {
|
header {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { onMount } from 'svelte'
|
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 FormField from '$lib/components/admin/FormField.svelte'
|
||||||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
||||||
import Editor from '$lib/components/admin/Editor.svelte'
|
import Editor from '$lib/components/admin/Editor.svelte'
|
||||||
|
|
@ -19,9 +19,23 @@
|
||||||
let slug = ''
|
let slug = ''
|
||||||
let excerpt = ''
|
let excerpt = ''
|
||||||
let linkUrl = ''
|
let linkUrl = ''
|
||||||
|
let linkDescription = ''
|
||||||
let content: JSONContent = { type: 'doc', content: [] }
|
let content: JSONContent = { type: 'doc', content: [] }
|
||||||
let tags: string[] = []
|
let tags: string[] = []
|
||||||
let tagInput = ''
|
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 () => {
|
onMount(async () => {
|
||||||
await loadPost()
|
await loadPost()
|
||||||
|
|
@ -42,11 +56,12 @@
|
||||||
post = await response.json()
|
post = await response.json()
|
||||||
// Populate form fields
|
// Populate form fields
|
||||||
title = post.title || ''
|
title = post.title || ''
|
||||||
postType = post.type
|
postType = post.type || post.postType
|
||||||
status = post.status
|
status = post.status
|
||||||
slug = post.slug || ''
|
slug = post.slug || ''
|
||||||
excerpt = post.excerpt || ''
|
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: [] }
|
content = post.content || { type: 'doc', content: [] }
|
||||||
tags = post.tags || []
|
tags = post.tags || []
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +83,7 @@
|
||||||
tags = tags.filter(t => t !== tag)
|
tags = tags.filter(t => t !== tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave(publishStatus?: 'draft' | 'published') {
|
||||||
const auth = localStorage.getItem('admin_auth')
|
const auth = localStorage.getItem('admin_auth')
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
goto('/admin/login')
|
goto('/admin/login')
|
||||||
|
|
@ -77,13 +92,14 @@
|
||||||
|
|
||||||
saving = true
|
saving = true
|
||||||
const postData = {
|
const postData = {
|
||||||
title,
|
title: config.showTitle ? title : null,
|
||||||
slug,
|
slug,
|
||||||
type: postType,
|
type: postType,
|
||||||
status,
|
status: publishStatus || status,
|
||||||
content,
|
content: config.showContent ? content : null,
|
||||||
excerpt: postType === 'blog' ? excerpt : undefined,
|
excerpt: postType === 'blog' ? excerpt : undefined,
|
||||||
link_url: postType === 'link' ? linkUrl : undefined,
|
link_url: postType === 'link' ? linkUrl : undefined,
|
||||||
|
link_description: postType === 'link' ? linkDescription : undefined,
|
||||||
tags
|
tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,6 +115,9 @@
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
post = await response.json()
|
post = await response.json()
|
||||||
|
if (publishStatus) {
|
||||||
|
status = publishStatus
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save post:', error)
|
console.error('Failed to save post:', error)
|
||||||
|
|
@ -129,18 +148,74 @@
|
||||||
console.error('Failed to delete post:', error)
|
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>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<AdminPage>
|
||||||
<header slot="header">
|
<header slot="header">
|
||||||
{#if !loading && post}
|
{#if !loading && post}
|
||||||
<h1>Edit Post</h1>
|
<div class="header-left">
|
||||||
<div class="actions">
|
<button class="btn-icon" onclick={() => goto('/admin/posts')}>
|
||||||
<button class="btn btn-danger" on:click={handleDelete}>Delete</button>
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
<button class="btn btn-secondary" on:click={() => goto('/admin/posts')}>Cancel</button>
|
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<button class="btn btn-primary" on:click={handleSave} disabled={saving}>
|
</svg>
|
||||||
{saving ? 'Saving...' : 'Save Changes'}
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -150,89 +225,122 @@
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
{:else if post}
|
{:else if post}
|
||||||
<div class="post-editor">
|
<div class="post-composer">
|
||||||
<div class="form-section">
|
<div class="main-content">
|
||||||
<FormFieldWrapper label="Post Type" required>
|
{#if config.showTitle}
|
||||||
<select bind:value={postType} class="form-select">
|
<input
|
||||||
<option value="blog">📝 Blog Post</option>
|
type="text"
|
||||||
<option value="microblog">💭 Microblog</option>
|
bind:value={title}
|
||||||
<option value="link">🔗 Link</option>
|
placeholder="Title"
|
||||||
<option value="photo">📷 Photo</option>
|
class="title-input"
|
||||||
<option value="album">🖼️ Photo Album</option>
|
/>
|
||||||
</select>
|
{/if}
|
||||||
</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'}
|
{#if postType === 'link'}
|
||||||
<FormField label="Link URL" bind:value={linkUrl} type="url" required />
|
<div class="link-fields">
|
||||||
{/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">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="url"
|
||||||
bind:value={tagInput}
|
bind:value={linkUrl}
|
||||||
on:keydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
placeholder="https://example.com"
|
||||||
placeholder="Add tags..."
|
class="link-url-input"
|
||||||
class="form-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>
|
</div>
|
||||||
{#if tags.length > 0}
|
{:else if postType === 'photo'}
|
||||||
<div class="tags">
|
<div class="photo-upload">
|
||||||
{#each tags as tag}
|
<div class="photo-placeholder">
|
||||||
<span class="tag">
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
{tag}
|
<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"/>
|
||||||
<button on:click={() => removeTag(tag)}>×</button>
|
<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"/>
|
||||||
</span>
|
</svg>
|
||||||
{/each}
|
<p>Click to upload photo</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
</FormFieldWrapper>
|
{:else if postType === 'album'}
|
||||||
|
<div class="album-upload">
|
||||||
<div class="metadata">
|
<div class="album-placeholder">
|
||||||
<p>Created: {new Date(post.created_at).toLocaleString()}</p>
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
<p>Updated: {new Date(post.updated_at).toLocaleString()}</p>
|
<rect x="8" y="8" width="24" height="24" rx="2" fill="currentColor" opacity="0.1"/>
|
||||||
{#if post.published_at}
|
<rect x="16" y="16" width="24" height="24" rx="2" fill="currentColor" opacity="0.2"/>
|
||||||
<p>Published: {new Date(post.published_at).toLocaleString()}</p>
|
<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"/>
|
||||||
{/if}
|
</svg>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{#if postType !== 'link'}
|
{#if showMetadata}
|
||||||
<div class="editor-section">
|
<aside class="metadata-sidebar">
|
||||||
<h2>Content</h2>
|
<h3>Post Settings</h3>
|
||||||
<Editor bind:data={content} />
|
|
||||||
</div>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="error">Post not found</div>
|
<div class="error">Post not found</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Page>
|
</AdminPage>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -240,120 +348,69 @@
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
gap: $unit-2x;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.75rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: $grey-10;
|
color: $grey-10;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.actions {
|
|
||||||
display: flex;
|
.header-actions {
|
||||||
gap: $unit-2x;
|
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 {
|
.btn-text {
|
||||||
display: grid;
|
padding: $unit $unit-2x;
|
||||||
gap: 2rem;
|
border: none;
|
||||||
grid-template-columns: 1fr;
|
background: none;
|
||||||
width: 100%;
|
color: $grey-40;
|
||||||
}
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
.form-section {
|
align-items: center;
|
||||||
display: grid;
|
gap: $unit;
|
||||||
gap: 1.5rem;
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-select,
|
|
||||||
.form-input,
|
|
||||||
.form-textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: $unit-2x $unit-3x;
|
|
||||||
border: 1px solid $grey-80;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
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-size: 0.875rem;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
h2 {
|
&:hover {
|
||||||
margin-bottom: 1rem;
|
background: $grey-90;
|
||||||
|
color: $grey-10;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.publish-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: $unit-2x $unit-3x;
|
padding: $unit-2x $unit-3x;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -362,43 +419,301 @@
|
||||||
font-size: 0.925rem;
|
font-size: 0.925rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.btn-primary {
|
&.btn-primary {
|
||||||
background-color: $grey-10;
|
background-color: $grey-10;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background-color: $grey-20;
|
background-color: $grey-20;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.btn-secondary {
|
&.btn-small {
|
||||||
background-color: $grey-80;
|
padding: $unit $unit-2x;
|
||||||
color: $grey-10;
|
font-size: 0.875rem;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background-color: $grey-60;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
&.btn-danger {
|
|
||||||
background-color: #dc2626;
|
.dropdown-menu {
|
||||||
color: white;
|
position: absolute;
|
||||||
|
top: calc(100% + $unit);
|
||||||
&:hover:not(:disabled) {
|
right: 0;
|
||||||
background-color: #b91c1c;
|
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 {
|
.error {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-text-secondary);
|
color: $grey-40;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
.post-composer {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,30 +1,55 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
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 FormField from '$lib/components/admin/FormField.svelte'
|
||||||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
||||||
import Editor from '$lib/components/admin/Editor.svelte'
|
import Editor from '$lib/components/admin/Editor.svelte'
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
let title = ''
|
// Get post type from URL params
|
||||||
let postType: 'blog' | 'microblog' | 'link' | 'photo' | 'album' = 'blog'
|
let postType: 'blog' | 'microblog' | 'link' | 'photo' | 'album' = 'blog'
|
||||||
|
|
||||||
|
let title = ''
|
||||||
let status: 'draft' | 'published' = 'draft'
|
let status: 'draft' | 'published' = 'draft'
|
||||||
let slug = ''
|
let slug = ''
|
||||||
let excerpt = ''
|
let excerpt = ''
|
||||||
let linkUrl = ''
|
let linkUrl = ''
|
||||||
|
let linkDescription = ''
|
||||||
let content: JSONContent = { type: 'doc', content: [] }
|
let content: JSONContent = { type: 'doc', content: [] }
|
||||||
let tags: string[] = []
|
let tags: string[] = []
|
||||||
let tagInput = ''
|
let tagInput = ''
|
||||||
|
let showMetadata = false
|
||||||
|
let isPublishDropdownOpen = false
|
||||||
|
let publishButtonRef: HTMLButtonElement
|
||||||
|
|
||||||
$: {
|
const postTypeConfig = {
|
||||||
// Auto-generate slug from title
|
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) {
|
if (title && !slug) {
|
||||||
slug = title
|
slug = title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
|
let config = $derived(postTypeConfig[postType])
|
||||||
|
|
||||||
function addTag() {
|
function addTag() {
|
||||||
if (tagInput && !tags.includes(tagInput)) {
|
if (tagInput && !tags.includes(tagInput)) {
|
||||||
|
|
@ -37,7 +62,7 @@
|
||||||
tags = tags.filter(t => t !== tag)
|
tags = tags.filter(t => t !== tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit(publishStatus: 'draft' | 'published') {
|
||||||
const auth = localStorage.getItem('admin_auth')
|
const auth = localStorage.getItem('admin_auth')
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
goto('/admin/login')
|
goto('/admin/login')
|
||||||
|
|
@ -45,13 +70,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const postData = {
|
const postData = {
|
||||||
title,
|
title: config.showTitle ? title : null,
|
||||||
slug,
|
slug,
|
||||||
type: postType,
|
type: postType,
|
||||||
status,
|
status: publishStatus,
|
||||||
content,
|
content: config.showContent ? content : null,
|
||||||
excerpt: postType === 'blog' ? excerpt : undefined,
|
excerpt: postType === 'blog' ? excerpt : undefined,
|
||||||
link_url: postType === 'link' ? linkUrl : undefined,
|
link_url: postType === 'link' ? linkUrl : undefined,
|
||||||
|
link_description: postType === 'link' ? linkDescription : undefined,
|
||||||
tags
|
tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,193 +99,231 @@
|
||||||
console.error('Failed to create post:', error)
|
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>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<AdminPage>
|
||||||
<header slot="header">
|
<header slot="header">
|
||||||
<h1>New Post</h1>
|
<div class="header-left">
|
||||||
<div class="actions">
|
<button class="btn-icon" onclick={() => goto('/admin/posts')}>
|
||||||
<button class="btn btn-secondary" on:click={() => goto('/admin/posts')}>Cancel</button>
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
<button class="btn btn-primary" on:click={handleSubmit}>Create Post</button>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="post-editor">
|
<div class="post-composer">
|
||||||
<div class="form-section">
|
<div class="main-content">
|
||||||
<FormFieldWrapper label="Post Type" required>
|
{#if config.showTitle}
|
||||||
<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">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={tagInput}
|
bind:value={title}
|
||||||
on:keydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
placeholder="Title"
|
||||||
placeholder="Add tags..."
|
class="title-input"
|
||||||
class="form-input"
|
|
||||||
/>
|
/>
|
||||||
<button type="button" on:click={addTag} class="btn btn-secondary">Add</button>
|
{/if}
|
||||||
</div>
|
|
||||||
{#if tags.length > 0}
|
{#if postType === 'link'}
|
||||||
<div class="tags">
|
<div class="link-fields">
|
||||||
{#each tags as tag}
|
<input
|
||||||
<span class="tag">
|
type="url"
|
||||||
{tag}
|
bind:value={linkUrl}
|
||||||
<button on:click={() => removeTag(tag)}>×</button>
|
placeholder="https://example.com"
|
||||||
</span>
|
class="link-url-input"
|
||||||
{/each}
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</FormFieldWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if postType !== 'link'}
|
|
||||||
<div class="editor-section">
|
|
||||||
<h2>Content</h2>
|
|
||||||
<Editor bind:data={content} />
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
{#if showMetadata}
|
||||||
</Page>
|
<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">
|
<style lang="scss">
|
||||||
header {
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 2rem;
|
gap: $unit-2x;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.75rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: $grey-10;
|
color: $grey-10;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.actions {
|
|
||||||
display: flex;
|
.header-actions {
|
||||||
gap: $unit-2x;
|
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 {
|
.btn-text {
|
||||||
display: grid;
|
padding: $unit $unit-2x;
|
||||||
gap: 2rem;
|
border: none;
|
||||||
grid-template-columns: 1fr;
|
background: none;
|
||||||
width: 100%;
|
color: $grey-40;
|
||||||
}
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
.form-section {
|
align-items: center;
|
||||||
display: grid;
|
gap: $unit;
|
||||||
gap: 1.5rem;
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-select,
|
|
||||||
.form-input,
|
|
||||||
.form-textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: $unit-2x $unit-3x;
|
|
||||||
border: 1px solid $grey-80;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
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-size: 0.875rem;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
h2 {
|
&:hover {
|
||||||
margin-bottom: 1rem;
|
background: $grey-90;
|
||||||
|
color: $grey-10;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.publish-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: $unit-2x $unit-3x;
|
padding: $unit-2x $unit-3x;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -268,23 +332,279 @@
|
||||||
font-size: 0.925rem;
|
font-size: 0.925rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
&.btn-primary {
|
&.btn-primary {
|
||||||
background-color: $grey-10;
|
background-color: $grey-10;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $grey-20;
|
background-color: $grey-20;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.btn-secondary {
|
&.btn-small {
|
||||||
background-color: $grey-80;
|
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;
|
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 {
|
&:hover {
|
||||||
background-color: $grey-60;
|
color: $grey-10;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
.post-composer {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { goto } from '$app/navigation'
|
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 ProjectListItem from '$lib/components/admin/ProjectListItem.svelte'
|
||||||
import ProjectTitleCell from '$lib/components/admin/ProjectTitleCell.svelte'
|
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -15,45 +15,30 @@
|
||||||
backgroundColor: string | null
|
backgroundColor: string | null
|
||||||
highlightColor: string | null
|
highlightColor: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let projects = $state<Project[]>([])
|
let projects = $state<Project[]>([])
|
||||||
let isLoading = $state(true)
|
let isLoading = $state(true)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let total = $state(0)
|
let showDeleteModal = $state(false)
|
||||||
|
let projectToDelete = $state<Project | null>(null)
|
||||||
const columns = [
|
let activeDropdown = $state<number | null>(null)
|
||||||
{
|
|
||||||
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' ? '🟢' : '⚪'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadProjects()
|
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() {
|
async function loadProjects() {
|
||||||
try {
|
try {
|
||||||
const auth = localStorage.getItem('admin_auth')
|
const auth = localStorage.getItem('admin_auth')
|
||||||
|
|
@ -76,7 +61,6 @@
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
projects = data.projects
|
projects = data.projects
|
||||||
total = data.pagination?.total || projects.length
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = 'Failed to load projects'
|
error = 'Failed to load projects'
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
@ -85,12 +69,79 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRowClick(project: Project) {
|
function handleToggleDropdown(event: CustomEvent<{ projectId: number; event: MouseEvent }>) {
|
||||||
goto(`/admin/projects/${project.id}/edit`)
|
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>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<AdminPage>
|
||||||
<header slot="header">
|
<header slot="header">
|
||||||
<h1>Projects</h1>
|
<h1>Projects</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
|
@ -100,23 +151,39 @@
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="error">{error}</div>
|
<div class="error">{error}</div>
|
||||||
{:else}
|
{:else if isLoading}
|
||||||
<div class="projects-stats">
|
<div class="loading">
|
||||||
<div class="stat">
|
<div class="spinner"></div>
|
||||||
<span class="stat-value">{total}</span>
|
<p>Loading projects...</p>
|
||||||
<span class="stat-label">Total projects</span>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
|
||||||
data={projects}
|
|
||||||
{columns}
|
|
||||||
loading={isLoading}
|
|
||||||
emptyMessage="No projects found. Create your first project!"
|
|
||||||
onRowClick={handleRowClick}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/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">
|
<style lang="scss">
|
||||||
header {
|
header {
|
||||||
|
|
@ -146,6 +213,8 @@
|
||||||
font-size: 0.925rem;
|
font-size: 0.925rem;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&.btn-primary {
|
&.btn-primary {
|
||||||
background-color: $grey-10;
|
background-color: $grey-10;
|
||||||
|
|
@ -164,28 +233,46 @@
|
||||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.projects-stats {
|
.loading {
|
||||||
display: flex;
|
padding: $unit-8x;
|
||||||
gap: $unit-4x;
|
text-align: center;
|
||||||
margin-bottom: $unit-4x;
|
color: $grey-40;
|
||||||
|
|
||||||
.stat {
|
.spinner {
|
||||||
display: flex;
|
width: 32px;
|
||||||
flex-direction: column;
|
height: 32px;
|
||||||
gap: $unit-half;
|
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 {
|
p {
|
||||||
font-size: 1.5rem;
|
margin: 0;
|
||||||
font-weight: 700;
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,91 +2,15 @@
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import { z } from 'zod'
|
import ProjectForm from '$lib/components/admin/ProjectForm.svelte'
|
||||||
import AdminSegmentedControl from '$lib/components/admin/AdminSegmentedControl.svelte'
|
import type { Project } from '$lib/types/project'
|
||||||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
|
||||||
import Editor from '$lib/components/admin/Editor.svelte'
|
|
||||||
|
|
||||||
// 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 project = $state<Project | null>(null)
|
||||||
let isLoading = $state(true)
|
let isLoading = $state(true)
|
||||||
let isSaving = $state(false)
|
|
||||||
let error = $state('')
|
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 projectId = $derived($page.params.id)
|
||||||
|
|
||||||
const tabOptions = [
|
|
||||||
{ value: 'metadata', label: 'Metadata' },
|
|
||||||
{ value: 'case-study', label: 'Case Study' }
|
|
||||||
]
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadProject()
|
await loadProject()
|
||||||
})
|
})
|
||||||
|
|
@ -109,23 +33,6 @@
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
project = data
|
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) {
|
} catch (err) {
|
||||||
error = 'Failed to load project'
|
error = 'Failed to load project'
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
@ -133,521 +40,19 @@
|
||||||
isLoading = false
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="admin-container">
|
{#if isLoading}
|
||||||
{#if isLoading}
|
<div class="loading">Loading project...</div>
|
||||||
<div class="loading">Loading project...</div>
|
{:else if error}
|
||||||
{:else if !project}
|
<div class="error">{error}</div>
|
||||||
<div class="error">Project not found</div>
|
{:else if !project}
|
||||||
{:else}
|
<div class="error">Project not found</div>
|
||||||
{#if error}
|
{:else}
|
||||||
<div class="error-message">{error}</div>
|
<ProjectForm {project} mode="edit" />
|
||||||
{/if}
|
{/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>
|
|
||||||
|
|
||||||
<style lang="scss">
|
<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,
|
.loading,
|
||||||
.error {
|
.error {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -659,143 +64,4 @@
|
||||||
.error {
|
.error {
|
||||||
color: #d33;
|
color: #d33;
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
.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>
|
|
||||||
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">
|
<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 Editor from '$lib/components/admin/Editor.svelte'
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
|
|
@ -108,7 +108,7 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page>
|
<AdminPage>
|
||||||
<header slot="header">
|
<header slot="header">
|
||||||
<h1>Upload Test</h1>
|
<h1>Upload Test</h1>
|
||||||
<a href="/admin/projects" class="back-link">← Back to Projects</a>
|
<a href="/admin/projects" class="back-link">← Back to Projects</a>
|
||||||
|
|
@ -179,7 +179,7 @@
|
||||||
<pre>{JSON.stringify(testContent, null, 2)}</pre>
|
<pre>{JSON.stringify(testContent, null, 2)}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</AdminPage>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
header {
|
header {
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ export const POST: RequestHandler = async (event) => {
|
||||||
role: body.role,
|
role: body.role,
|
||||||
technologies: body.technologies || [],
|
technologies: body.technologies || [],
|
||||||
featuredImage: body.featuredImage,
|
featuredImage: body.featuredImage,
|
||||||
|
logoUrl: body.logoUrl,
|
||||||
gallery: body.gallery || [],
|
gallery: body.gallery || [],
|
||||||
externalUrl: body.externalUrl,
|
externalUrl: body.externalUrl,
|
||||||
caseStudyContent: body.caseStudyContent,
|
caseStudyContent: body.caseStudyContent,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||||
role: body.role ?? existing.role,
|
role: body.role ?? existing.role,
|
||||||
technologies: body.technologies ?? existing.technologies,
|
technologies: body.technologies ?? existing.technologies,
|
||||||
featuredImage: body.featuredImage ?? existing.featuredImage,
|
featuredImage: body.featuredImage ?? existing.featuredImage,
|
||||||
|
logoUrl: body.logoUrl ?? existing.logoUrl,
|
||||||
gallery: body.gallery ?? existing.gallery,
|
gallery: body.gallery ?? existing.gallery,
|
||||||
externalUrl: body.externalUrl ?? existing.externalUrl,
|
externalUrl: body.externalUrl ?? existing.externalUrl,
|
||||||
caseStudyContent: body.caseStudyContent ?? existing.caseStudyContent,
|
caseStudyContent: body.caseStudyContent ?? existing.caseStudyContent,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const config = {
|
||||||
$illos: 'src/assets/illos',
|
$illos: 'src/assets/illos',
|
||||||
$components: 'src/lib/components',
|
$components: 'src/lib/components',
|
||||||
$utils: 'src/lib/utils',
|
$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'
|
import svg from '@poppanator/sveltekit-svg'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
watch: {
|
||||||
|
usePolling: true
|
||||||
|
}
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
sveltekit(),
|
sveltekit(),
|
||||||
svg({
|
svg({
|
||||||
|
|
@ -59,7 +64,8 @@ export default defineConfig({
|
||||||
@import './src/assets/styles/fonts.scss';
|
@import './src/assets/styles/fonts.scss';
|
||||||
@import './src/assets/styles/themes.scss';
|
@import './src/assets/styles/themes.scss';
|
||||||
@import './src/assets/styles/globals.scss';
|
@import './src/assets/styles/globals.scss';
|
||||||
`
|
`,
|
||||||
|
api: 'modern-compiler'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
postcss: {
|
postcss: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue