New project + Edit project working

* Can fill out metadata
* Uploads SVGs for logos
* Editor works and persists/loads data
This commit is contained in:
Justin Edmund 2025-05-29 20:19:01 -07:00
parent 80d54aaaf0
commit 4fde0e6148
34 changed files with 3910 additions and 1669 deletions

284
package-lock.json generated
View file

@ -69,7 +69,7 @@
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"@types/eslint": "^8.56.7",
"@types/node": "^22.0.2",
"autoprefixer": "^10.4.19",
@ -108,7 +108,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
@ -134,7 +133,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
@ -150,7 +148,6 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -166,7 +163,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -182,7 +178,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -198,7 +193,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -214,7 +208,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -230,7 +223,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@ -246,7 +238,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@ -262,7 +253,6 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -278,7 +268,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -294,7 +283,6 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -310,7 +298,6 @@
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -326,7 +313,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -342,7 +328,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -358,7 +343,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -374,7 +358,6 @@
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -390,7 +373,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -423,7 +405,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@ -456,7 +437,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@ -472,7 +452,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
@ -488,7 +467,6 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -504,7 +482,6 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -520,7 +497,6 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -1105,7 +1081,6 @@
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
"dev": true,
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
@ -1119,7 +1094,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"engines": {
"node": ">=6.0.0"
}
@ -1128,7 +1102,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true,
"engines": {
"node": ">=6.0.0"
}
@ -1143,7 +1116,6 @@
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"dev": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
@ -1205,8 +1177,7 @@
"node_modules/@polka/url": {
"version": "1.0.0-next.25",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz",
"integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==",
"dev": true
"integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ=="
},
"node_modules/@poppanator/sveltekit-svg": {
"version": "5.0.0-svelte5.4",
@ -1702,6 +1673,15 @@
"win32"
]
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
}
},
"node_modules/@sveltejs/adapter-auto": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.2.2.tgz",
@ -1732,7 +1712,6 @@
"version": "2.5.18",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.18.tgz",
"integrity": "sha512-+g06hvpVAnH7b4CDjhnTDgFWBKBiQJpuSmQeGYOuzbO3SC3tdYjRNlDCrafvDtKbGiT2uxY5Dn9qdEUGVZdWOQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@types/cookie": "^0.6.0",
@ -1761,59 +1740,43 @@
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",
"integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",
"dev": true,
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz",
"integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==",
"license": "MIT",
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
"debug": "^4.3.4",
"@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0",
"debug": "^4.3.7",
"deepmerge": "^4.3.1",
"kleur": "^4.1.5",
"magic-string": "^0.30.10",
"svelte-hmr": "^0.16.0",
"vitefu": "^0.2.5"
"magic-string": "^0.30.12",
"vitefu": "^1.0.3"
},
"engines": {
"node": "^18.0.0 || >=20"
"node": "^18.0.0 || ^20.0.0 || >=22"
},
"peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0-next.0",
"svelte": "^5.0.0-next.96 || ^5.0.0",
"vite": "^5.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
"integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
"dev": true,
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz",
"integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4"
"debug": "^4.3.7"
},
"engines": {
"node": "^18.0.0 || >=20"
"node": "^18.0.0 || ^20.0.0 || >=22"
},
"peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0",
"svelte": "^5.0.0-next.96 || ^5.0.0",
"vite": "^5.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte/node_modules/svelte-hmr": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",
"integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^12.20 || ^14.13.1 || >= 16"
},
"peerDependencies": {
"svelte": "^3.19.0 || ^4.0.0"
}
},
"node_modules/@tiptap/core": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.12.0.tgz",
@ -2455,8 +2418,7 @@
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"node_modules/@types/eslint": {
"version": "8.56.10",
@ -2847,10 +2809,10 @@
}
},
"node_modules/acorn": {
"version": "8.12.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
"integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==",
"dev": true,
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
@ -2867,15 +2829,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-typescript": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz",
"integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==",
"dev": true,
"peerDependencies": {
"acorn": ">=8.9.0"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -2917,7 +2870,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"devOptional": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
@ -2938,12 +2891,12 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"dependencies": {
"dequal": "^2.0.3"
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/array-union": {
@ -3032,12 +2985,12 @@
"license": "MIT"
},
"node_modules/axobject-query": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz",
"integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==",
"dev": true,
"dependencies": {
"dequal": "^2.0.3"
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/balanced-match": {
@ -3058,7 +3011,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=8"
},
@ -3086,7 +3039,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"devOptional": true,
"dependencies": {
"fill-range": "^7.1.1"
},
@ -3218,7 +3171,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"devOptional": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@ -3242,7 +3195,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"devOptional": true,
"dependencies": {
"is-glob": "^4.0.1"
},
@ -3263,6 +3216,15 @@
"node": ">=9"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@ -3362,7 +3324,6 @@
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
@ -3576,8 +3537,7 @@
"node_modules/devalue": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.0.0.tgz",
"integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA==",
"dev": true
"integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA=="
},
"node_modules/devlop": {
"version": "1.1.0",
@ -3718,7 +3678,6 @@
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
@ -3913,10 +3872,10 @@
}
},
"node_modules/esm-env": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz",
"integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==",
"dev": true
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
},
"node_modules/espree": {
"version": "10.1.0",
@ -3961,13 +3920,12 @@
}
},
"node_modules/esrap": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.2.2.tgz",
"integrity": "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==",
"dev": true,
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz",
"integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
"@types/estree": "^1.0.1"
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/esrecurse": {
@ -4130,7 +4088,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"devOptional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
@ -4351,8 +4309,7 @@
"node_modules/globalyzer": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
"integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==",
"dev": true
"integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="
},
"node_modules/globby": {
"version": "11.1.0",
@ -4377,8 +4334,7 @@
"node_modules/globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
"dev": true
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="
},
"node_modules/graceful-fs": {
"version": "4.2.11",
@ -4509,7 +4465,7 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz",
"integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==",
"dev": true
"devOptional": true
},
"node_modules/import-fresh": {
"version": "3.3.0",
@ -4531,7 +4487,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
"integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==",
"dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@ -4596,7 +4551,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"devOptional": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
@ -4645,7 +4600,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=0.10.0"
}
@ -4662,7 +4617,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"devOptional": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
@ -4679,7 +4634,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=0.12.0"
}
@ -4694,14 +4649,20 @@
}
},
"node_modules/is-reference": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
"integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==",
"dev": true,
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"dependencies": {
"@types/estree": "*"
"@types/estree": "^1.0.6"
}
},
"node_modules/is-reference/node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"license": "MIT"
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
@ -4887,7 +4848,6 @@
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"dev": true,
"engines": {
"node": ">=6"
}
@ -4938,8 +4898,7 @@
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
},
"node_modules/locate-path": {
"version": "6.0.0",
@ -5168,7 +5127,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"dev": true,
"engines": {
"node": ">=4"
}
@ -5177,7 +5135,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
"integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==",
"dev": true,
"engines": {
"node": ">=10"
}
@ -5210,7 +5167,6 @@
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true,
"funding": [
{
"type": "github",
@ -5286,7 +5242,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=0.10.0"
}
@ -5497,7 +5453,6 @@
"version": "8.4.39",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -5967,7 +5922,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"devOptional": true,
"dependencies": {
"picomatch": "^2.2.1"
},
@ -6170,7 +6125,6 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dev": true,
"dependencies": {
"mri": "^1.1.0"
},
@ -6220,7 +6174,7 @@
"version": "1.77.8",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz",
"integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==",
"dev": true,
"devOptional": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@ -6261,8 +6215,7 @@
"node_modules/set-cookie-parser": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
"integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==",
"dev": true
"integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ=="
},
"node_modules/sharp": {
"version": "0.34.2",
@ -6348,7 +6301,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
"dev": true,
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
@ -6609,23 +6561,24 @@
}
},
"node_modules/svelte": {
"version": "5.0.0-next.169",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.0.0-next.169.tgz",
"integrity": "sha512-8VD4/adoVW5Oo4Kub+jnWnuexAtskjbLuAhy3IGMkSKcQ6RVKEQPo4j+8Xr58epow6ccA7S5zp+PsiTv4eW5Zg==",
"dev": true,
"version": "5.33.6",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.33.6.tgz",
"integrity": "sha512-bxg2QY03JlrilCZmDlshY95Argj0rnX43UQFWZN4fct8PZTNBBmvfow2A6yOW1+YweDjhC2qdZF66ASI0Y21Tw==",
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
"@jridgewell/sourcemap-codec": "^1.4.15",
"@ampproject/remapping": "^2.3.0",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"acorn": "^8.11.3",
"acorn-typescript": "^1.4.13",
"aria-query": "^5.3.0",
"axobject-query": "^4.0.0",
"esm-env": "^1.0.0",
"esrap": "^1.2.2",
"is-reference": "^3.0.2",
"acorn": "^8.12.1",
"aria-query": "^5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"esm-env": "^1.2.1",
"esrap": "^1.4.6",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.5",
"magic-string": "^0.30.11",
"zimmerframe": "^1.1.2"
},
"engines": {
@ -6840,7 +6793,6 @@
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
"integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==",
"dev": true,
"dependencies": {
"globalyzer": "0.1.0",
"globrex": "^0.1.2"
@ -6917,7 +6869,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"devOptional": true,
"dependencies": {
"is-number": "^7.0.0"
},
@ -6929,7 +6881,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"engines": {
"node": ">=6"
}
@ -7477,7 +7428,7 @@
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
"dev": true,
"devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -7598,7 +7549,6 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.2.tgz",
"integrity": "sha512-6lA7OBHBlXUxiJxbO5aAY2fsHHzDr1q7DvXYnyZycRs2Dz+dXBWuhpWHvmljTRTpQC2uvGmUFFkSHF2vGo90MA==",
"dev": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.38",
@ -7650,13 +7600,16 @@
}
},
"node_modules/vitefu": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
"integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
"dev": true,
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.6.tgz",
"integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==",
"license": "MIT",
"workspaces": [
"tests/deps/*",
"tests/projects/*"
],
"peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
},
"peerDependenciesMeta": {
"vite": {
@ -7850,8 +7803,7 @@
"node_modules/zimmerframe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"dev": true
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="
},
"node_modules/zod": {
"version": "3.25.30",

View file

@ -21,7 +21,7 @@
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"@types/eslint": "^8.56.7",
"@types/node": "^22.0.2",
"autoprefixer": "^10.4.19",
@ -101,5 +101,8 @@
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"overrides": {
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6"
}
}

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "logoUrl" VARCHAR(500);

View file

@ -22,6 +22,7 @@ model Project {
role String? @db.VarChar(255)
technologies Json? // Array of tech stack
featuredImage String? @db.VarChar(500)
logoUrl String? @db.VarChar(500)
gallery Json? // Array of image URLs
externalUrl String? @db.VarChar(500)
caseStudyContent Json? // BlockNote JSON format

View file

@ -82,6 +82,8 @@ $red-60: #e33d3d;
$red-40: #d31919;
$red-00: #3d0c0c;
$salmon-pink: #ffbdb3; // Desaturated salmon pink for hover states
$bg-color: #e8e8e8;
$page-color: #ffffff;
$card-color: #f7f7f7;

View 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>

View 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>

View file

@ -13,6 +13,7 @@
isLoading?: boolean
emptyMessage?: string
onRowClick?: (item: T) => void
unstyled?: boolean
}
let {
@ -20,7 +21,8 @@
columns = [],
isLoading = false,
emptyMessage = 'No data found',
onRowClick
onRowClick,
unstyled = false
}: Props<any> = $props()
function getCellValue(item: any, column: Column<any>) {
@ -39,7 +41,7 @@
}
</script>
<div class="data-table-wrapper">
<div class="data-table-wrapper" class:unstyled>
{#if isLoading}
<div class="loading">
<div class="spinner"></div>
@ -85,6 +87,11 @@
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
&.unstyled {
border-radius: 0;
box-shadow: none;
}
}
.loading {

View 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>

View file

@ -38,7 +38,7 @@
initialized = true
return
}
const json = props.editor.getJSON()
data = json
onChange?.(json)
@ -72,9 +72,9 @@
<div class="editor-wrapper {className}" style="--min-height: {minHeight}px">
<div class="editor-container">
<EditorWithUpload
bind:editor
content={data}
<EditorWithUpload
bind:editor
content={data}
{onUpdate}
editable={!readOnly}
{showToolbar}
@ -88,8 +88,8 @@
</div>
<style lang="scss">
@import '$lib/../assets/styles/variables.scss';
@import '$lib/../assets/styles/mixins.scss';
@import '$styles/variables.scss';
@import '$styles/mixins.scss';
.editor-wrapper {
width: 100%;
@ -117,16 +117,17 @@
flex-direction: column;
min-height: 0;
}
:global(.editor-content .edra) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.editor-content .editor-toolbar) {
border-bottom: 1px solid $grey-80;
border-radius: $card-corner-radius;
box-sizing: border-box;
background: $grey-95;
padding: $unit-2x;
position: sticky;
@ -136,20 +137,23 @@
overflow-y: hidden;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
// Hide scrollbar but keep functionality
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
// Override Edra toolbar styles
:global(.edra-toolbar) {
overflow: visible;
width: auto;
padding: 0;
display: flex;
align-items: center;
gap: $unit;
}
}
@ -159,8 +163,8 @@
min-height: 0;
height: 100%;
overflow-y: auto;
padding: $unit-4x;
padding: 0 $unit-4x;
@include breakpoint('phone') {
padding: $unit-3x;
}
@ -286,7 +290,7 @@
color: $grey-50;
gap: $unit;
}
// Image styles
:global(.edra .ProseMirror img) {
max-width: 100%;
@ -298,11 +302,11 @@
display: block;
object-fit: contain;
}
:global(.edra-media-placeholder-wrapper) {
margin: $unit-2x 0;
}
:global(.edra-media-placeholder-content) {
display: flex;
flex-direction: column;
@ -315,51 +319,51 @@
background: $grey-95;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: $grey-60;
background: $grey-90;
}
}
:global(.edra-media-placeholder-icon) {
width: 48px;
height: 48px;
color: $grey-50;
}
:global(.edra-media-placeholder-text) {
font-size: 1rem;
color: $grey-30;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
// Image container styles
:global(.edra-media-container) {
margin: $unit-3x auto;
position: relative;
&.align-left {
margin-left: 0;
}
&.align-right {
margin-right: 0;
margin-left: auto;
}
&.align-center {
margin-left: auto;
margin-right: auto;
}
}
:global(.edra-media-content) {
width: 100%;
height: auto;
display: block;
}
:global(.edra-media-caption) {
width: 100%;
margin-top: $unit;
@ -370,11 +374,11 @@
color: $grey-30;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
background: $grey-95;
&:focus {
outline: none;
border-color: $grey-60;
background: white;
}
}
</style>
</style>

View file

@ -5,7 +5,9 @@
import { EdraToolbar, EdraBubbleMenu } from '$lib/components/edra/headless/index.js'
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
import { focusEditor, type EdraProps } from '$lib/components/edra/utils.js'
import EdraToolBarIcon from '$lib/components/edra/headless/components/EdraToolBarIcon.svelte'
import { commands } from '$lib/components/edra/commands/commands.js'
// Import all the same components as Edra
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
import { all, createLowlight } from 'lowlight'
@ -32,15 +34,15 @@
import { IFramePlaceholder } from '$lib/components/edra/extensions/iframe/IFramePlaceholder.js'
import { IFrameExtended } from '$lib/components/edra/extensions/iframe/IFrameExtended.js'
import IFrameExtendedComponent from '$lib/components/edra/headless/components/IFrameExtended.svelte'
// Import Edra styles
import '$lib/components/edra/headless/style.css'
import 'katex/dist/katex.min.css'
import '$lib/components/edra/editor.css'
import '$lib/components/edra/onedark.css'
const lowlight = createLowlight(all)
let {
class: className = '',
content = undefined,
@ -54,50 +56,192 @@
showToolbar = true,
placeholder = 'Type "/" for commands...'
}: EdraProps & { showToolbar?: boolean; placeholder?: string } = $props()
let element = $state<HTMLElement>()
let isLoading = $state(true)
let showTextStyleDropdown = $state(false)
let showMediaDropdown = $state(false)
let dropdownTriggerRef = $state<HTMLElement>()
let mediaDropdownTriggerRef = $state<HTMLElement>()
let dropdownPosition = $state({ top: 0, left: 0 })
let mediaDropdownPosition = $state({ top: 0, left: 0 })
// Filter out unwanted commands
const getFilteredCommands = () => {
const filtered = { ...commands }
// Remove these groups entirely
delete filtered['undo-redo']
delete filtered['headings'] // In text style dropdown
delete filtered['lists'] // In text style dropdown
delete filtered['alignment'] // Not needed
delete filtered['table'] // Not needed
delete filtered['media'] // Will be in media dropdown
// Reorganize text-formatting commands
if (filtered['text-formatting']) {
const allCommands = filtered['text-formatting'].commands
const basicFormatting = []
const advancedFormatting = []
// Group basic formatting first
const basicOrder = ['bold', 'italic', 'underline', 'strike']
basicOrder.forEach(name => {
const cmd = allCommands.find(c => c.name === name)
if (cmd) basicFormatting.push(cmd)
})
// Then link and code
const advancedOrder = ['link', 'code']
advancedOrder.forEach(name => {
const cmd = allCommands.find(c => c.name === name)
if (cmd) advancedFormatting.push(cmd)
})
// Create two groups
filtered['basic-formatting'] = {
name: 'Basic Formatting',
label: 'Basic Formatting',
commands: basicFormatting
}
filtered['advanced-formatting'] = {
name: 'Advanced Formatting',
label: 'Advanced Formatting',
commands: advancedFormatting
}
// Remove original text-formatting
delete filtered['text-formatting']
}
return filtered
}
// Get media commands, but filter out iframe
const getMediaCommands = () => {
if (commands.media) {
return commands.media.commands.filter(cmd => cmd.name !== 'iframe-placeholder')
}
return []
}
const filteredCommands = getFilteredCommands()
const colorCommands = commands.colors.commands
const fontCommands = commands.fonts.commands
const excludedCommands = ['colors', 'fonts']
// Get current text style for dropdown
const getCurrentTextStyle = (editor: Editor) => {
if (editor.isActive('heading', { level: 1 })) return 'Heading 1'
if (editor.isActive('heading', { level: 2 })) return 'Heading 2'
if (editor.isActive('heading', { level: 3 })) return 'Heading 3'
if (editor.isActive('bulletList')) return 'Bullet List'
if (editor.isActive('orderedList')) return 'Ordered List'
if (editor.isActive('taskList')) return 'Task List'
if (editor.isActive('codeBlock')) return 'Code Block'
if (editor.isActive('blockquote')) return 'Blockquote'
return 'Paragraph'
}
// Calculate dropdown position
const updateDropdownPosition = () => {
if (dropdownTriggerRef) {
const rect = dropdownTriggerRef.getBoundingClientRect()
dropdownPosition = {
top: rect.bottom + 4,
left: rect.left
}
}
}
// Toggle dropdown with position update
const toggleDropdown = () => {
if (!showTextStyleDropdown) {
updateDropdownPosition()
}
showTextStyleDropdown = !showTextStyleDropdown
}
// Update media dropdown position
const updateMediaDropdownPosition = () => {
if (mediaDropdownTriggerRef) {
const rect = mediaDropdownTriggerRef.getBoundingClientRect()
mediaDropdownPosition = {
top: rect.bottom + 4,
left: rect.left
}
}
}
// Toggle media dropdown
const toggleMediaDropdown = () => {
if (!showMediaDropdown) {
updateMediaDropdownPosition()
}
showMediaDropdown = !showMediaDropdown
}
// Close dropdown when clicking outside
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!dropdownTriggerRef?.contains(target) && !target.closest('.dropdown-menu-portal')) {
showTextStyleDropdown = false
}
if (!mediaDropdownTriggerRef?.contains(target) && !target.closest('.media-dropdown-portal')) {
showMediaDropdown = false
}
}
$effect(() => {
if (showTextStyleDropdown || showMediaDropdown) {
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
}
}
})
// Custom image paste handler
function handleImagePaste(view: any, event: ClipboardEvent) {
const item = event.clipboardData?.items[0]
if (item?.type.indexOf('image') !== 0) {
return false
}
const file = item.getAsFile()
if (!file) return false
// Check file size (2MB max)
const filesize = file.size / 1024 / 1024
if (filesize > 2) {
alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`)
return true
}
// Upload to our media API
uploadImage(file)
return true // Prevent default paste behavior
}
async function uploadImage(file: File) {
if (!editor) return
// Create a placeholder while uploading
const placeholderSrc = URL.createObjectURL(file)
editor.commands.setImage({ src: placeholderSrc })
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
throw new Error('Not authenticated')
}
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/media/upload', {
method: 'POST',
headers: {
@ -105,17 +249,17 @@
},
body: formData
})
if (!response.ok) {
throw new Error('Upload failed')
}
const media = await response.json()
// Replace placeholder with actual URL
// Set a reasonable default width (max 600px)
const displayWidth = media.width && media.width > 600 ? 600 : media.width
editor.commands.insertContent({
type: 'image',
attrs: {
@ -126,7 +270,7 @@
align: 'center'
}
})
// Clean up the object URL
URL.revokeObjectURL(placeholderSrc)
} catch (error) {
@ -136,7 +280,7 @@
editor.commands.undo()
}
}
onMount(() => {
editor = initiateEditor(
element,
@ -175,18 +319,18 @@
}
}
)
// Add placeholder
if (placeholder && editor) {
editor.extensionManager.extensions.find(
ext => ext.name === 'placeholder'
)?.configure({
placeholder
})
editor.extensionManager.extensions
.find((ext) => ext.name === 'placeholder')
?.configure({
placeholder
})
}
isLoading = false
return () => editor?.destroy()
})
</script>
@ -194,7 +338,81 @@
<div class={`edra ${className}`}>
{#if showToolbar && editor && !isLoading}
<div class="editor-toolbar">
<EdraToolbar {editor} />
<div class="edra-toolbar">
<!-- Text Style Dropdown -->
<div class="text-style-dropdown">
<button
bind:this={dropdownTriggerRef}
class="dropdown-trigger"
onclick={toggleDropdown}
>
<span>{getCurrentTextStyle(editor)}</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<span class="separator"></span>
{#each Object.keys(filteredCommands).filter((key) => !excludedCommands.includes(key)) as keys}
{@const groups = filteredCommands[keys].commands}
{#each groups as command}
<EdraToolBarIcon {command} {editor} />
{/each}
<span class="separator"></span>
{/each}
<!-- Media Dropdown -->
<div class="text-style-dropdown">
<button
bind:this={mediaDropdownTriggerRef}
class="dropdown-trigger"
onclick={toggleMediaDropdown}
>
<span>Insert</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<span class="separator"></span>
<EdraToolBarIcon
command={colorCommands[0]}
{editor}
style={`color: ${editor.getAttributes('textStyle').color};`}
onclick={() => {
const color = editor.getAttributes('textStyle').color;
const hasColor = editor.isActive('textStyle', { color });
if (hasColor) {
editor.chain().focus().unsetColor().run();
} else {
const color = prompt('Enter the color of the text:');
if (color !== null) {
editor.chain().focus().setColor(color).run();
}
}
}}
/>
<EdraToolBarIcon
command={colorCommands[1]}
{editor}
style={`background-color: ${editor.getAttributes('highlight').color};`}
onclick={() => {
const hasHightlight = editor.isActive('highlight');
if (hasHightlight) {
editor.chain().focus().unsetHighlight().run();
} else {
const color = prompt('Enter the color of the highlight:');
if (color !== null) {
editor.chain().focus().setHighlight({ color }).run();
}
}
}}
/>
</div>
</div>
{/if}
{#if editor}
@ -225,6 +443,115 @@
></div>
</div>
<!-- Media Dropdown Portal -->
{#if showMediaDropdown}
<div
class="media-dropdown-portal"
style="position: fixed; top: {mediaDropdownPosition.top}px; left: {mediaDropdownPosition.left}px; z-index: 10000;"
>
<div class="dropdown-menu">
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().insertImagePlaceholder().run()
showMediaDropdown = false
}}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<rect x="3" y="5" width="14" height="10" stroke="currentColor" stroke-width="2" fill="none" rx="1"/>
<circle cx="7" cy="9" r="1.5" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M3 12L7 8L10 11L13 8L17 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
<span>Image</span>
</button>
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().insertVideoPlaceholder().run()
showMediaDropdown = false
}}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<rect x="3" y="4" width="14" height="12" stroke="currentColor" stroke-width="2" fill="none" rx="2"/>
<path d="M8 8.5L12 10L8 11.5V8.5Z" fill="currentColor"/>
</svg>
<span>Video</span>
</button>
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().insertAudioPlaceholder().run()
showMediaDropdown = false
}}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<path d="M10 4L10 16M6 8L6 12M14 8L14 12M2 6L2 14M18 6L18 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>Audio</span>
</button>
</div>
</div>
{/if}
<!-- Dropdown Menu Portal -->
{#if showTextStyleDropdown}
<div
class="dropdown-menu-portal"
style="position: fixed; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; z-index: 10000;"
>
<div class="dropdown-menu">
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().setParagraph().run()
showTextStyleDropdown = false
}}>
Paragraph
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().toggleHeading({ level: 1 }).run()
showTextStyleDropdown = false
}}>
Heading 1
</button>
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().toggleHeading({ level: 2 }).run()
showTextStyleDropdown = false
}}>
Heading 2
</button>
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().toggleHeading({ level: 3 }).run()
showTextStyleDropdown = false
}}>
Heading 3
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().toggleBulletList().run()
showTextStyleDropdown = false
}}>
Unordered List
</button>
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().toggleOrderedList().run()
showTextStyleDropdown = false
}}>
Ordered List
</button>
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().toggleTaskList().run()
showTextStyleDropdown = false
}}>
Task List
</button>
<div class="dropdown-separator"></div>
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().toggleCodeBlock().run()
showTextStyleDropdown = false
}}>
Code Block
</button>
<button class="dropdown-item" onclick={() => {
editor?.chain().focus().toggleBlockquote().run()
showTextStyleDropdown = false
}}>
Blockquote
</button>
</div>
</div>
{/if}
<style>
.edra {
width: 100%;
@ -233,10 +560,10 @@
flex-direction: column;
height: 100%;
}
.editor-toolbar {
border-bottom: 1px solid var(--edra-border-color);
background: var(--edra-button-bg-color);
box-sizing: border-box;
padding: 0.5rem;
position: sticky;
top: 0;
@ -248,14 +575,15 @@
width: 100%;
flex-shrink: 0;
}
.edra-editor {
width: 100%;
flex: 1;
min-width: 0;
overflow-x: hidden;
box-sizing: border-box;
}
:global(.ProseMirror) {
width: 100%;
min-height: 100%;
@ -269,4 +597,109 @@
outline: none;
}
}
</style>
/* Text Style Dropdown Styles */
.text-style-dropdown {
position: relative;
display: inline-block;
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
color: var(--edra-text-color);
cursor: pointer;
transition: all 0.2s ease;
min-width: 120px;
justify-content: space-between;
height: 36px;
}
.dropdown-trigger:hover {
background: rgba(0, 0, 0, 0.06);
border-color: transparent;
}
.dropdown-menu {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
min-width: 160px;
overflow: hidden;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 16px;
text-align: left;
background: none;
border: none;
font-size: 14px;
font-family: inherit;
color: var(--edra-text-color);
cursor: pointer;
transition: background-color 0.2s ease;
}
.dropdown-item:hover {
background-color: #f5f5f5;
}
.dropdown-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
}
.dropdown-separator {
height: 1px;
background-color: #e0e0e0;
margin: 4px 0;
}
/* Separator in toolbar */
:global(.edra-toolbar .separator) {
display: inline-block;
width: 2px;
height: 24px;
background-color: #e0e0e0;
border-radius: 1px;
margin: 0 4px;
vertical-align: middle;
}
/* Remove default button backgrounds */
:global(.edra-toolbar button) {
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
transition: all 0.2s ease;
}
:global(.edra-toolbar button:hover) {
background: rgba(0, 0, 0, 0.06);
border-color: transparent;
}
:global(.edra-toolbar button.active),
:global(.edra-toolbar button[data-active="true"]) {
background: rgba(0, 0, 0, 0.1);
border-color: transparent;
}
/* Thicker strokes for icons */
:global(.edra-toolbar svg) {
stroke-width: 2;
}
</style>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -45,10 +45,11 @@
}
.edra-editor {
padding: var(--edra-padding);
padding: 0 var(--edra-padding);
flex-grow: 1;
padding-left: 2rem;
overflow: auto;
box-sizing: border-box;
}
.edra-toolbar {
@ -113,7 +114,7 @@
.separator {
width: var(--edra-separator-width);
background-color: var(--edra-separator-color);
/* background-color: var(--edra-separator-color); */
}
.edra-media-placeholder-wrapper {

View 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
View 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' }]
}
}

View file

@ -2,7 +2,7 @@
import { page } from '$app/stores'
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import AdminSegmentedController from '$lib/components/admin/AdminSegmentedController.svelte'
import AdminNavBar from '$lib/components/admin/AdminNavBar.svelte'
let { children } = $props()
@ -36,9 +36,7 @@
{:else}
<!-- Authenticated, show admin layout -->
<div class="admin-container">
<header class="admin-header">
<AdminSegmentedController />
</header>
<AdminNavBar />
<main class="admin-content">
{@render children()}
@ -61,18 +59,12 @@
min-height: 100vh;
display: flex;
flex-direction: column;
}
.admin-header {
display: flex;
justify-content: center;
padding: $unit-6x 0 $unit-4x;
background-color: $bg-color;
}
.admin-content {
flex: 1;
background-color: $bg-color;
padding-top: $unit-4x;
padding-bottom: $unit-6x;
}
</style>

View file

@ -1,229 +1,29 @@
<script lang="ts">
import { onMount } from 'svelte'
import Page from '$lib/components/Page.svelte'
interface Stats {
projects: number
posts: number
albums: number
photos: number
media: number
}
let stats = $state<Stats | null>(null)
let isLoading = $state(true)
let error = $state('')
onMount(async () => {
await loadStats()
})
async function loadStats() {
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) return
// Fetch counts from each endpoint
const [projectsRes, postsRes, albumsRes, mediaRes] = await Promise.all([
fetch('/api/projects?limit=1', {
headers: { Authorization: `Basic ${auth}` }
}),
fetch('/api/posts?limit=1', {
headers: { Authorization: `Basic ${auth}` }
}),
fetch('/api/albums?limit=1', {
headers: { Authorization: `Basic ${auth}` }
}),
fetch('/api/media?limit=1', {
headers: { Authorization: `Basic ${auth}` }
})
])
const projectsData = projectsRes.ok ? await projectsRes.json() : { pagination: { total: 0 } }
const postsData = postsRes.ok ? await postsRes.json() : { pagination: { total: 0 } }
const albumsData = albumsRes.ok ? await albumsRes.json() : { pagination: { total: 0 } }
const mediaData = mediaRes.ok ? await mediaRes.json() : { pagination: { total: 0 } }
// TODO: Get photo count once we have that endpoint
const photoCount = 0
stats = {
projects: projectsData.pagination?.total || 0,
posts: postsData.pagination?.total || 0,
albums: albumsData.pagination?.total || 0,
photos: photoCount,
media: mediaData.pagination?.total || 0
}
} catch (err) {
error = 'Failed to load statistics'
console.error(err)
} finally {
isLoading = false
}
}
import AdminPage from '$lib/components/admin/AdminPage.svelte'
</script>
<Page>
<header slot="header">
<h1>Dashboard</h1>
</header>
{#if isLoading}
<div class="loading">Loading statistics...</div>
{:else if error}
<div class="error">{error}</div>
{:else if stats}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">💼</div>
<div class="stat-content">
<div class="stat-value">{stats.projects}</div>
<div class="stat-label">Projects</div>
</div>
<a href="/admin/projects" class="stat-link">View all →</a>
</div>
<div class="stat-card">
<div class="stat-icon">🌟</div>
<div class="stat-content">
<div class="stat-value">{stats.posts}</div>
<div class="stat-label">Universe Posts</div>
</div>
<a href="/admin/posts" class="stat-link">View all →</a>
</div>
<div class="stat-card">
<div class="stat-icon">📸</div>
<div class="stat-content">
<div class="stat-value">{stats.albums}</div>
<div class="stat-label">Photo Albums</div>
</div>
<a href="/admin/albums" class="stat-link">View all →</a>
</div>
<div class="stat-card">
<div class="stat-icon">🖼️</div>
<div class="stat-content">
<div class="stat-value">{stats.media}</div>
<div class="stat-label">Media Files</div>
</div>
<a href="/admin/media" class="stat-link">View all →</a>
</div>
</div>
<section class="quick-actions">
<h2>Quick Actions</h2>
<div class="action-grid">
<a href="/admin/projects/new" class="action-card">
<span class="action-icon"></span>
<span>New Project</span>
</a>
<a href="/admin/posts/new" class="action-card">
<span class="action-icon">✏️</span>
<span>Write Post</span>
</a>
<a href="/admin/albums/new" class="action-card">
<span class="action-icon">📷</span>
<span>Create Album</span>
</a>
<a href="/admin/media" class="action-card">
<span class="action-icon">⬆️</span>
<span>Upload Media</span>
</a>
</div>
</section>
{/if}
</Page>
<AdminPage>
<div class="action-grid">
<a href="/admin/projects/new" class="action-card">
<span class="action-icon"></span>
<span>New Project</span>
</a>
<a href="/admin/posts/new" class="action-card">
<span class="action-icon">✏️</span>
<span>Write Post</span>
</a>
<a href="/admin/albums/new" class="action-card">
<span class="action-icon">📷</span>
<span>Create Album</span>
</a>
<a href="/admin/media" class="action-card">
<span class="action-icon">⬆️</span>
<span>Upload Media</span>
</a>
</div>
</AdminPage>
<style lang="scss">
header {
h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 0;
color: $grey-10;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
}
.loading,
.error {
text-align: center;
padding: $unit-6x;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.error {
color: #d33;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $unit-3x;
margin-bottom: $unit-6x;
}
.stat-card {
background: $grey-95;
border-radius: $unit-2x;
padding: $unit-4x;
display: flex;
flex-direction: column;
gap: $unit-3x;
transition: background-color 0.2s ease;
&:hover {
background-color: $grey-90;
}
}
.stat-icon {
font-size: 2rem;
line-height: 1;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
line-height: 1;
margin-bottom: $unit-half;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
color: $grey-10;
}
.stat-label {
color: $grey-40;
font-size: 0.875rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.stat-link {
color: $grey-30;
text-decoration: none;
font-size: 0.875rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
&:hover {
color: $grey-10;
}
}
.quick-actions {
h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 $unit-3x;
color: $grey-10;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
}
.action-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte'
import Page from '$lib/components/Page.svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import type { Media } from '@prisma/client'
let media = $state<Media[]>([])
@ -83,7 +83,7 @@
}
</script>
<Page>
<AdminPage>
<header slot="header">
<h1>Media Library</h1>
<div class="header-actions">
@ -207,7 +207,7 @@
</div>
{/if}
{/if}
</Page>
</AdminPage>
<style lang="scss">
header {

View file

@ -1,8 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import Page from '$lib/components/Page.svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import DataTable from '$lib/components/admin/DataTable.svelte'
import PostDropdown from '$lib/components/admin/PostDropdown.svelte'
interface Post {
id: number
@ -139,11 +140,11 @@
}
</script>
<Page>
<AdminPage>
<header slot="header">
<h1>Posts</h1>
<div class="header-actions">
<a href="/admin/posts/new" class="btn btn-primary">New Post</a>
<PostDropdown />
</div>
</header>
@ -171,7 +172,7 @@
onRowClick={handleRowClick}
/>
{/if}
</Page>
</AdminPage>
<style lang="scss">
header {

View file

@ -2,7 +2,7 @@
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { onMount } from 'svelte'
import Page from '$lib/components/Page.svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import FormField from '$lib/components/admin/FormField.svelte'
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
import Editor from '$lib/components/admin/Editor.svelte'
@ -19,9 +19,23 @@
let slug = ''
let excerpt = ''
let linkUrl = ''
let linkDescription = ''
let content: JSONContent = { type: 'doc', content: [] }
let tags: string[] = []
let tagInput = ''
let showMetadata = false
let isPublishDropdownOpen = false
let publishButtonRef: HTMLButtonElement
const postTypeConfig = {
blog: { icon: '📝', label: 'Blog Post', showTitle: true, showContent: true },
microblog: { icon: '💭', label: 'Microblog', showTitle: false, showContent: true },
link: { icon: '🔗', label: 'Link', showTitle: true, showContent: false },
photo: { icon: '📷', label: 'Photo', showTitle: true, showContent: false },
album: { icon: '🖼️', label: 'Album', showTitle: true, showContent: false }
}
let config = $derived(postTypeConfig[postType])
onMount(async () => {
await loadPost()
@ -42,11 +56,12 @@
post = await response.json()
// Populate form fields
title = post.title || ''
postType = post.type
postType = post.type || post.postType
status = post.status
slug = post.slug || ''
excerpt = post.excerpt || ''
linkUrl = post.link_url || ''
linkUrl = post.link_url || post.linkUrl || ''
linkDescription = post.link_description || post.linkDescription || ''
content = post.content || { type: 'doc', content: [] }
tags = post.tags || []
}
@ -68,7 +83,7 @@
tags = tags.filter(t => t !== tag)
}
async function handleSave() {
async function handleSave(publishStatus?: 'draft' | 'published') {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
@ -77,13 +92,14 @@
saving = true
const postData = {
title,
title: config.showTitle ? title : null,
slug,
type: postType,
status,
content,
status: publishStatus || status,
content: config.showContent ? content : null,
excerpt: postType === 'blog' ? excerpt : undefined,
link_url: postType === 'link' ? linkUrl : undefined,
link_description: postType === 'link' ? linkDescription : undefined,
tags
}
@ -99,6 +115,9 @@
if (response.ok) {
post = await response.json()
if (publishStatus) {
status = publishStatus
}
}
} catch (error) {
console.error('Failed to save post:', error)
@ -129,18 +148,74 @@
console.error('Failed to delete post:', error)
}
}
function handlePublishDropdown(event: MouseEvent) {
if (!publishButtonRef?.contains(event.target as Node)) {
isPublishDropdownOpen = false
}
}
$effect(() => {
if (isPublishDropdownOpen) {
document.addEventListener('click', handlePublishDropdown)
return () => document.removeEventListener('click', handlePublishDropdown)
}
})
</script>
<Page>
<AdminPage>
<header slot="header">
{#if !loading && post}
<h1>Edit Post</h1>
<div class="actions">
<button class="btn btn-danger" on:click={handleDelete}>Delete</button>
<button class="btn btn-secondary" on:click={() => goto('/admin/posts')}>Cancel</button>
<button class="btn btn-primary" on:click={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
<div class="header-left">
<button class="btn-icon" onclick={() => goto('/admin/posts')}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h1>{config.icon} Edit {config.label}</h1>
</div>
<div class="header-actions">
<button class="btn btn-text" onclick={handleDelete}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 4L12 12M4 12L12 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Delete
</button>
<button class="btn btn-text" onclick={() => showMetadata = !showMetadata}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 4V8L10 10M14 8C14 11.3137 11.3137 14 8 14C4.68629 14 2 11.3137 2 8C2 4.68629 4.68629 2 8 2C11.3137 2 8 4.68629 8 8Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Metadata
</button>
{#if status === 'draft'}
<div class="publish-dropdown">
<button
bind:this={publishButtonRef}
class="btn btn-primary"
onclick={(e) => { e.stopPropagation(); isPublishDropdownOpen = !isPublishDropdownOpen }}
disabled={saving}
>
{saving ? 'Saving...' : 'Publish'}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{#if isPublishDropdownOpen}
<div class="dropdown-menu">
<button class="dropdown-item" onclick={() => handleSave('published')}>
<span>Publish now</span>
</button>
<button class="dropdown-item" onclick={() => handleSave('draft')}>
<span>Keep as draft</span>
</button>
</div>
{/if}
</div>
{:else}
<button class="btn btn-primary" onclick={() => handleSave()} disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</button>
{/if}
</div>
{/if}
</header>
@ -150,89 +225,122 @@
<LoadingSpinner />
</div>
{:else if post}
<div class="post-editor">
<div class="form-section">
<FormFieldWrapper label="Post Type" required>
<select bind:value={postType} class="form-select">
<option value="blog">📝 Blog Post</option>
<option value="microblog">💭 Microblog</option>
<option value="link">🔗 Link</option>
<option value="photo">📷 Photo</option>
<option value="album">🖼️ Photo Album</option>
</select>
</FormFieldWrapper>
<FormField label="Title" bind:value={title} required />
<div class="post-composer">
<div class="main-content">
{#if config.showTitle}
<input
type="text"
bind:value={title}
placeholder="Title"
class="title-input"
/>
{/if}
<FormField label="Slug" bind:value={slug} required />
{#if postType === 'blog'}
<FormFieldWrapper label="Excerpt">
<textarea
bind:value={excerpt}
class="form-textarea"
rows="3"
placeholder="Brief description of the post..."
/>
</FormFieldWrapper>
{/if}
{#if postType === 'link'}
<FormField label="Link URL" bind:value={linkUrl} type="url" required />
{/if}
<FormFieldWrapper label="Status">
<select bind:value={status} class="form-select">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</FormFieldWrapper>
<FormFieldWrapper label="Tags">
<div class="tag-input-wrapper">
<div class="link-fields">
<input
type="text"
bind:value={tagInput}
on:keydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
placeholder="Add tags..."
class="form-input"
type="url"
bind:value={linkUrl}
placeholder="https://example.com"
class="link-url-input"
required
/>
<textarea
bind:value={linkDescription}
class="link-description"
rows="3"
placeholder="What makes this link interesting?"
/>
<button type="button" on:click={addTag} class="btn btn-secondary">Add</button>
</div>
{#if tags.length > 0}
<div class="tags">
{#each tags as tag}
<span class="tag">
{tag}
<button on:click={() => removeTag(tag)}>×</button>
</span>
{/each}
{:else if postType === 'photo'}
<div class="photo-upload">
<div class="photo-placeholder">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<path d="M40 14H31.5L28 10H20L16.5 14H8C5.8 14 4 15.8 4 18V34C4 36.2 5.8 38 8 38H40C42.2 38 44 36.2 44 34V18C44 15.8 42.2 14 40 14ZM24 32C19.6 32 16 28.4 16 24C16 19.6 19.6 16 24 16C28.4 16 32 19.6 32 24C32 28.4 28.4 32 24 32Z" fill="currentColor" opacity="0.1"/>
<path d="M24 28C26.2091 28 28 26.2091 28 24C28 21.7909 26.2091 20 24 20C21.7909 20 20 21.7909 20 24C20 26.2091 21.7909 28 24 28Z" fill="currentColor" opacity="0.3"/>
</svg>
<p>Click to upload photo</p>
</div>
{/if}
</FormFieldWrapper>
<div class="metadata">
<p>Created: {new Date(post.created_at).toLocaleString()}</p>
<p>Updated: {new Date(post.updated_at).toLocaleString()}</p>
{#if post.published_at}
<p>Published: {new Date(post.published_at).toLocaleString()}</p>
{/if}
</div>
</div>
{:else if postType === 'album'}
<div class="album-upload">
<div class="album-placeholder">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="8" y="8" width="24" height="24" rx="2" fill="currentColor" opacity="0.1"/>
<rect x="16" y="16" width="24" height="24" rx="2" fill="currentColor" opacity="0.2"/>
<path d="M16 24L20 20L24 24L32 16L40 24V38C40 39.1046 39.1046 40 38 40H18C16.8954 40 16 39.1046 16 38V24Z" fill="currentColor" opacity="0.3"/>
</svg>
<p>Click to upload photos</p>
<span class="album-hint">Select multiple photos</span>
</div>
</div>
{/if}
{#if config.showContent}
<div class="editor-wrapper">
<Editor bind:data={content} placeholder="Continue writing..." />
</div>
{/if}
</div>
{#if postType !== 'link'}
<div class="editor-section">
<h2>Content</h2>
<Editor bind:data={content} />
</div>
{#if showMetadata}
<aside class="metadata-sidebar">
<h3>Post Settings</h3>
<FormField label="Slug" bind:value={slug} />
{#if postType === 'blog'}
<FormFieldWrapper label="Excerpt">
<textarea
bind:value={excerpt}
class="form-textarea"
rows="3"
placeholder="Brief description..."
/>
</FormFieldWrapper>
{/if}
<FormFieldWrapper label="Tags">
<div class="tag-input-wrapper">
<input
type="text"
bind:value={tagInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
placeholder="Add tags..."
class="form-input"
/>
<button type="button" onclick={addTag} class="btn btn-small">Add</button>
</div>
{#if tags.length > 0}
<div class="tags">
{#each tags as tag}
<span class="tag">
{tag}
<button onclick={() => removeTag(tag)}>×</button>
</span>
{/each}
</div>
{/if}
</FormFieldWrapper>
<div class="metadata">
<p>Created: {new Date(post.created_at || post.createdAt).toLocaleString()}</p>
<p>Updated: {new Date(post.updated_at || post.updatedAt).toLocaleString()}</p>
{#if post.published_at || post.publishedAt}
<p>Published: {new Date(post.published_at || post.publishedAt).toLocaleString()}</p>
{/if}
</div>
</aside>
{/if}
</div>
{:else}
<div class="error">Post not found</div>
{/if}
</Page>
</AdminPage>
<style lang="scss">
@import '$styles/variables.scss';
.loading-container {
display: flex;
justify-content: center;
@ -240,120 +348,69 @@
min-height: 400px;
}
header {
.header-left {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: $unit-2x;
h1 {
font-size: 1.75rem;
font-size: 1.5rem;
font-weight: 700;
margin: 0;
color: $grey-10;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.actions {
display: flex;
gap: $unit-2x;
}
.header-actions {
display: flex;
align-items: center;
gap: $unit-2x;
}
.btn-icon {
width: 40px;
height: 40px;
border: none;
background: none;
color: $grey-40;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: $grey-90;
color: $grey-10;
}
}
.post-editor {
display: grid;
gap: 2rem;
grid-template-columns: 1fr;
width: 100%;
}
.form-section {
display: grid;
gap: 1.5rem;
max-width: 600px;
}
.form-select,
.form-input,
.form-textarea {
width: 100%;
padding: $unit-2x $unit-3x;
border: 1px solid $grey-80;
.btn-text {
padding: $unit $unit-2x;
border: none;
background: none;
color: $grey-40;
cursor: pointer;
display: flex;
align-items: center;
gap: $unit;
border-radius: 8px;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 1rem;
background-color: $grey-90;
&:focus {
outline: none;
border-color: $grey-50;
}
}
.form-textarea {
resize: vertical;
}
.tag-input-wrapper {
display: flex;
gap: 0.5rem;
input {
flex: 1;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: $unit $unit-2x;
background: $grey-80;
border-radius: 20px;
font-size: 0.875rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
button {
background: none;
border: none;
color: $grey-40;
cursor: pointer;
padding: 0;
font-size: 1.2em;
line-height: 1;
&:hover {
color: $grey-10;
}
}
}
.metadata {
font-size: 0.875rem;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
p {
margin: $unit-half 0;
}
}
.editor-section {
width: 100%;
min-width: 0; // Prevent overflow
transition: all 0.2s ease;
h2 {
margin-bottom: 1rem;
&:hover {
background: $grey-90;
color: $grey-10;
}
}
.publish-dropdown {
position: relative;
}
.btn {
padding: $unit-2x $unit-3x;
border: none;
@ -362,43 +419,301 @@
font-size: 0.925rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: $unit;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.btn-primary {
background-color: $grey-10;
color: white;
&:hover:not(:disabled) {
background-color: $grey-20;
}
}
&.btn-secondary {
background-color: $grey-80;
color: $grey-10;
&:hover:not(:disabled) {
background-color: $grey-60;
}
&.btn-small {
padding: $unit $unit-2x;
font-size: 0.875rem;
}
&.btn-danger {
background-color: #dc2626;
color: white;
&:hover:not(:disabled) {
background-color: #b91c1c;
}
.dropdown-menu {
position: absolute;
top: calc(100% + $unit);
right: 0;
background: white;
border: 1px solid $grey-80;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-width: 150px;
z-index: 100;
overflow: hidden;
}
.dropdown-item {
width: 100%;
padding: $unit-2x;
border: none;
background: none;
cursor: pointer;
text-align: left;
transition: background 0.2s ease;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 0.875rem;
color: $grey-10;
&:hover {
background: $grey-95;
}
&:not(:last-child) {
border-bottom: 1px solid $grey-90;
}
}
.post-composer {
display: grid;
grid-template-columns: 1fr;
gap: $unit-4x;
&:has(.metadata-sidebar) {
grid-template-columns: 1fr 300px;
}
}
.main-content {
display: flex;
flex-direction: column;
gap: $unit-3x;
min-width: 0;
}
.title-input {
width: 100%;
padding: 0;
border: none;
font-size: 2.5rem;
font-weight: 700;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
color: $grey-10;
background: none;
&:focus {
outline: none;
}
&::placeholder {
color: $grey-60;
}
}
.link-fields {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.link-url-input {
width: 100%;
padding: $unit-2x $unit-3x;
border: 1px solid $grey-80;
border-radius: 8px;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 1.125rem;
background-color: $grey-95;
&:focus {
outline: none;
border-color: $grey-50;
background-color: white;
}
}
.link-description {
width: 100%;
padding: $unit-2x $unit-3x;
border: 1px solid $grey-80;
border-radius: 8px;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 1rem;
background-color: $grey-95;
resize: vertical;
&:focus {
outline: none;
border-color: $grey-50;
background-color: white;
}
}
.photo-upload,
.album-upload {
width: 100%;
}
.photo-placeholder,
.album-placeholder {
border: 2px dashed $grey-80;
border-radius: 12px;
padding: $unit-8x;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit-2x;
cursor: pointer;
transition: all 0.2s ease;
background: $grey-95;
&:hover {
border-color: $grey-60;
background: $grey-90;
}
svg {
color: $grey-50;
}
p {
margin: 0;
color: $grey-30;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
}
.album-hint {
font-size: 0.875rem;
color: $grey-50;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.editor-wrapper {
width: 100%;
min-height: 400px;
}
.metadata-sidebar {
background: $grey-95;
border-radius: 12px;
padding: $unit-3x;
height: fit-content;
position: sticky;
top: $unit-3x;
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 $unit-3x;
color: $grey-10;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
> * + * {
margin-top: $unit-3x;
}
}
.form-textarea {
width: 100%;
padding: $unit-2x;
border: 1px solid $grey-80;
border-radius: 8px;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 0.875rem;
background-color: white;
resize: vertical;
&:focus {
outline: none;
border-color: $grey-50;
}
}
.form-input {
width: 100%;
padding: $unit $unit-2x;
border: 1px solid $grey-80;
border-radius: 8px;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 0.875rem;
background-color: white;
&:focus {
outline: none;
border-color: $grey-50;
}
}
.tag-input-wrapper {
display: flex;
gap: $unit;
input {
flex: 1;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: $unit;
margin-top: $unit;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px $unit-2x;
background: $grey-80;
border-radius: 20px;
font-size: 0.75rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
button {
background: none;
border: none;
color: $grey-40;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
&:hover {
color: $grey-10;
}
}
}
.metadata {
font-size: 0.75rem;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
p {
margin: $unit-half 0;
}
}
.error {
text-align: center;
color: var(--color-text-secondary);
color: $grey-40;
padding: 2rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
@include breakpoint('phone') {
.post-composer {
grid-template-columns: 1fr;
}
.metadata-sidebar {
position: static;
}
}
</style>

View file

@ -1,30 +1,55 @@
<script lang="ts">
import { goto } from '$app/navigation'
import Page from '$lib/components/Page.svelte'
import { page } from '$app/stores'
import { onMount } from 'svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import FormField from '$lib/components/admin/FormField.svelte'
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
import Editor from '$lib/components/admin/Editor.svelte'
import type { JSONContent } from '@tiptap/core'
let title = ''
// Get post type from URL params
let postType: 'blog' | 'microblog' | 'link' | 'photo' | 'album' = 'blog'
let title = ''
let status: 'draft' | 'published' = 'draft'
let slug = ''
let excerpt = ''
let linkUrl = ''
let linkDescription = ''
let content: JSONContent = { type: 'doc', content: [] }
let tags: string[] = []
let tagInput = ''
let showMetadata = false
let isPublishDropdownOpen = false
let publishButtonRef: HTMLButtonElement
$: {
// Auto-generate slug from title
const postTypeConfig = {
blog: { icon: '📝', label: 'Blog Post', showTitle: true, showContent: true },
microblog: { icon: '💭', label: 'Microblog', showTitle: false, showContent: true },
link: { icon: '🔗', label: 'Link', showTitle: true, showContent: false },
photo: { icon: '📷', label: 'Photo', showTitle: true, showContent: false },
album: { icon: '🖼️', label: 'Album', showTitle: true, showContent: false }
}
onMount(() => {
const type = $page.url.searchParams.get('type')
if (type && type in postTypeConfig) {
postType = type as typeof postType
}
})
// Auto-generate slug from title
$effect(() => {
if (title && !slug) {
slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
}
})
let config = $derived(postTypeConfig[postType])
function addTag() {
if (tagInput && !tags.includes(tagInput)) {
@ -37,7 +62,7 @@
tags = tags.filter(t => t !== tag)
}
async function handleSubmit() {
async function handleSubmit(publishStatus: 'draft' | 'published') {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
@ -45,13 +70,14 @@
}
const postData = {
title,
title: config.showTitle ? title : null,
slug,
type: postType,
status,
content,
status: publishStatus,
content: config.showContent ? content : null,
excerpt: postType === 'blog' ? excerpt : undefined,
link_url: postType === 'link' ? linkUrl : undefined,
link_description: postType === 'link' ? linkDescription : undefined,
tags
}
@ -73,193 +99,231 @@
console.error('Failed to create post:', error)
}
}
function handlePublishDropdown(event: MouseEvent) {
if (!publishButtonRef?.contains(event.target as Node)) {
isPublishDropdownOpen = false
}
}
$effect(() => {
if (isPublishDropdownOpen) {
document.addEventListener('click', handlePublishDropdown)
return () => document.removeEventListener('click', handlePublishDropdown)
}
})
</script>
<Page>
<AdminPage>
<header slot="header">
<h1>New Post</h1>
<div class="actions">
<button class="btn btn-secondary" on:click={() => goto('/admin/posts')}>Cancel</button>
<button class="btn btn-primary" on:click={handleSubmit}>Create Post</button>
<div class="header-left">
<button class="btn-icon" onclick={() => goto('/admin/posts')}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h1>{config.icon} New {config.label}</h1>
</div>
<div class="header-actions">
<button class="btn btn-text" onclick={() => showMetadata = !showMetadata}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 4V8L10 10M14 8C14 11.3137 11.3137 14 8 14C4.68629 14 2 11.3137 2 8C2 4.68629 4.68629 2 8 2C11.3137 2 8 4.68629 8 8Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Metadata
</button>
<div class="publish-dropdown">
<button
bind:this={publishButtonRef}
class="btn btn-primary"
onclick={(e) => { e.stopPropagation(); isPublishDropdownOpen = !isPublishDropdownOpen }}
>
Post
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{#if isPublishDropdownOpen}
<div class="dropdown-menu">
<button class="dropdown-item" onclick={() => handleSubmit('published')}>
<span>Publish now</span>
</button>
<button class="dropdown-item" onclick={() => handleSubmit('draft')}>
<span>Save as draft</span>
</button>
</div>
{/if}
</div>
</div>
</header>
<div class="post-editor">
<div class="form-section">
<FormFieldWrapper label="Post Type" required>
<select bind:value={postType} class="form-select">
<option value="blog">📝 Blog Post</option>
<option value="microblog">💭 Microblog</option>
<option value="link">🔗 Link</option>
<option value="photo">📷 Photo</option>
<option value="album">🖼️ Photo Album</option>
</select>
</FormFieldWrapper>
<FormField label="Title" bind:value={title} required />
<FormField label="Slug" bind:value={slug} required />
{#if postType === 'blog'}
<FormFieldWrapper label="Excerpt">
<textarea
bind:value={excerpt}
class="form-textarea"
rows="3"
placeholder="Brief description of the post..."
/>
</FormFieldWrapper>
{/if}
{#if postType === 'link'}
<FormField label="Link URL" bind:value={linkUrl} type="url" required />
{/if}
<FormFieldWrapper label="Status">
<select bind:value={status} class="form-select">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</FormFieldWrapper>
<FormFieldWrapper label="Tags">
<div class="tag-input-wrapper">
<div class="post-composer">
<div class="main-content">
{#if config.showTitle}
<input
type="text"
bind:value={tagInput}
on:keydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
placeholder="Add tags..."
class="form-input"
bind:value={title}
placeholder="Title"
class="title-input"
/>
<button type="button" on:click={addTag} class="btn btn-secondary">Add</button>
</div>
{#if tags.length > 0}
<div class="tags">
{#each tags as tag}
<span class="tag">
{tag}
<button on:click={() => removeTag(tag)}>×</button>
</span>
{/each}
{/if}
{#if postType === 'link'}
<div class="link-fields">
<input
type="url"
bind:value={linkUrl}
placeholder="https://example.com"
class="link-url-input"
required
/>
<textarea
bind:value={linkDescription}
class="link-description"
rows="3"
placeholder="What makes this link interesting?"
/>
</div>
{:else if postType === 'photo'}
<div class="photo-upload">
<div class="photo-placeholder">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<path d="M40 14H31.5L28 10H20L16.5 14H8C5.8 14 4 15.8 4 18V34C4 36.2 5.8 38 8 38H40C42.2 38 44 36.2 44 34V18C44 15.8 42.2 14 40 14ZM24 32C19.6 32 16 28.4 16 24C16 19.6 19.6 16 24 16C28.4 16 32 19.6 32 24C32 28.4 28.4 32 24 32Z" fill="currentColor" opacity="0.1"/>
<path d="M24 28C26.2091 28 28 26.2091 28 24C28 21.7909 26.2091 20 24 20C21.7909 20 20 21.7909 20 24C20 26.2091 21.7909 28 24 28Z" fill="currentColor" opacity="0.3"/>
</svg>
<p>Click to upload photo</p>
</div>
</div>
{:else if postType === 'album'}
<div class="album-upload">
<div class="album-placeholder">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="8" y="8" width="24" height="24" rx="2" fill="currentColor" opacity="0.1"/>
<rect x="16" y="16" width="24" height="24" rx="2" fill="currentColor" opacity="0.2"/>
<path d="M16 24L20 20L24 24L32 16L40 24V38C40 39.1046 39.1046 40 38 40H18C16.8954 40 16 39.1046 16 38V24Z" fill="currentColor" opacity="0.3"/>
</svg>
<p>Click to upload photos</p>
<span class="album-hint">Select multiple photos</span>
</div>
</div>
{/if}
{#if config.showContent}
<div class="editor-wrapper">
<Editor bind:data={content} placeholder="Start writing..." />
</div>
{/if}
</FormFieldWrapper>
</div>
{#if postType !== 'link'}
<div class="editor-section">
<h2>Content</h2>
<Editor bind:data={content} />
</div>
{/if}
</div>
</Page>
{#if showMetadata}
<aside class="metadata-sidebar">
<h3>Post Settings</h3>
<FormField label="Slug" bind:value={slug} />
{#if postType === 'blog'}
<FormFieldWrapper label="Excerpt">
<textarea
bind:value={excerpt}
class="form-textarea"
rows="3"
placeholder="Brief description..."
/>
</FormFieldWrapper>
{/if}
<FormFieldWrapper label="Tags">
<div class="tag-input-wrapper">
<input
type="text"
bind:value={tagInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
placeholder="Add tags..."
class="form-input"
/>
<button type="button" onclick={addTag} class="btn btn-small">Add</button>
</div>
{#if tags.length > 0}
<div class="tags">
{#each tags as tag}
<span class="tag">
{tag}
<button onclick={() => removeTag(tag)}>×</button>
</span>
{/each}
</div>
{/if}
</FormFieldWrapper>
</aside>
{/if}
</div>
</AdminPage>
<style lang="scss">
header {
@import '$styles/variables.scss';
.header-left {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
gap: $unit-2x;
h1 {
font-size: 1.75rem;
font-size: 1.5rem;
font-weight: 700;
margin: 0;
color: $grey-10;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.actions {
display: flex;
gap: $unit-2x;
}
.header-actions {
display: flex;
align-items: center;
gap: $unit-2x;
}
.btn-icon {
width: 40px;
height: 40px;
border: none;
background: none;
color: $grey-40;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: $grey-90;
color: $grey-10;
}
}
.post-editor {
display: grid;
gap: 2rem;
grid-template-columns: 1fr;
width: 100%;
}
.form-section {
display: grid;
gap: 1.5rem;
max-width: 600px;
}
.form-select,
.form-input,
.form-textarea {
width: 100%;
padding: $unit-2x $unit-3x;
border: 1px solid $grey-80;
.btn-text {
padding: $unit $unit-2x;
border: none;
background: none;
color: $grey-40;
cursor: pointer;
display: flex;
align-items: center;
gap: $unit;
border-radius: 8px;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 1rem;
background-color: $grey-90;
&:focus {
outline: none;
border-color: $grey-50;
}
}
.form-textarea {
resize: vertical;
}
.tag-input-wrapper {
display: flex;
gap: 0.5rem;
input {
flex: 1;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: $unit $unit-2x;
background: $grey-80;
border-radius: 20px;
font-size: 0.875rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
button {
background: none;
border: none;
color: $grey-40;
cursor: pointer;
padding: 0;
font-size: 1.2em;
line-height: 1;
&:hover {
color: $grey-10;
}
}
}
.editor-section {
width: 100%;
min-width: 0; // Prevent overflow
transition: all 0.2s ease;
h2 {
margin-bottom: 1rem;
&:hover {
background: $grey-90;
color: $grey-10;
}
}
.publish-dropdown {
position: relative;
}
.btn {
padding: $unit-2x $unit-3x;
border: none;
@ -268,23 +332,279 @@
font-size: 0.925rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: $unit;
&.btn-primary {
background-color: $grey-10;
color: white;
&:hover {
background-color: $grey-20;
}
}
&.btn-secondary {
background-color: $grey-80;
&.btn-small {
padding: $unit $unit-2x;
font-size: 0.875rem;
}
}
.dropdown-menu {
position: absolute;
top: calc(100% + $unit);
right: 0;
background: white;
border: 1px solid $grey-80;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-width: 150px;
z-index: 100;
overflow: hidden;
}
.dropdown-item {
width: 100%;
padding: $unit-2x;
border: none;
background: none;
cursor: pointer;
text-align: left;
transition: background 0.2s ease;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 0.875rem;
color: $grey-10;
&:hover {
background: $grey-95;
}
&:not(:last-child) {
border-bottom: 1px solid $grey-90;
}
}
.post-composer {
display: grid;
grid-template-columns: 1fr;
gap: $unit-4x;
&:has(.metadata-sidebar) {
grid-template-columns: 1fr 300px;
}
}
.main-content {
display: flex;
flex-direction: column;
gap: $unit-3x;
min-width: 0;
}
.title-input {
width: 100%;
padding: 0;
border: none;
font-size: 2.5rem;
font-weight: 700;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
color: $grey-10;
background: none;
&:focus {
outline: none;
}
&::placeholder {
color: $grey-60;
}
}
.link-fields {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.link-url-input {
width: 100%;
padding: $unit-2x $unit-3x;
border: 1px solid $grey-80;
border-radius: 8px;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 1.125rem;
background-color: $grey-95;
&:focus {
outline: none;
border-color: $grey-50;
background-color: white;
}
}
.link-description {
width: 100%;
padding: $unit-2x $unit-3x;
border: 1px solid $grey-80;
border-radius: 8px;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 1rem;
background-color: $grey-95;
resize: vertical;
&:focus {
outline: none;
border-color: $grey-50;
background-color: white;
}
}
.photo-upload,
.album-upload {
width: 100%;
}
.photo-placeholder,
.album-placeholder {
border: 2px dashed $grey-80;
border-radius: 12px;
padding: $unit-8x;
display: flex;
flex-direction: column;
align-items: center;
gap: $unit-2x;
cursor: pointer;
transition: all 0.2s ease;
background: $grey-95;
&:hover {
border-color: $grey-60;
background: $grey-90;
}
svg {
color: $grey-50;
}
p {
margin: 0;
color: $grey-30;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
}
.album-hint {
font-size: 0.875rem;
color: $grey-50;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.editor-wrapper {
width: 100%;
min-height: 400px;
}
.metadata-sidebar {
background: $grey-95;
border-radius: 12px;
padding: $unit-3x;
height: fit-content;
position: sticky;
top: $unit-3x;
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 $unit-3x;
color: $grey-10;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
> * + * {
margin-top: $unit-3x;
}
}
.form-textarea {
width: 100%;
padding: $unit-2x;
border: 1px solid $grey-80;
border-radius: 8px;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 0.875rem;
background-color: white;
resize: vertical;
&:focus {
outline: none;
border-color: $grey-50;
}
}
.form-input {
width: 100%;
padding: $unit $unit-2x;
border: 1px solid $grey-80;
border-radius: 8px;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-size: 0.875rem;
background-color: white;
&:focus {
outline: none;
border-color: $grey-50;
}
}
.tag-input-wrapper {
display: flex;
gap: $unit;
input {
flex: 1;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: $unit;
margin-top: $unit;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px $unit-2x;
background: $grey-80;
border-radius: 20px;
font-size: 0.75rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
button {
background: none;
border: none;
color: $grey-40;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
&:hover {
background-color: $grey-60;
color: $grey-10;
}
}
}
@include breakpoint('phone') {
.post-composer {
grid-template-columns: 1fr;
}
.metadata-sidebar {
position: static;
}
}
</style>

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import Page from '$lib/components/Page.svelte'
import DataTable from '$lib/components/admin/DataTable.svelte'
import ProjectTitleCell from '$lib/components/admin/ProjectTitleCell.svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import ProjectListItem from '$lib/components/admin/ProjectListItem.svelte'
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
interface Project {
id: number
@ -15,45 +15,30 @@
backgroundColor: string | null
highlightColor: string | null
createdAt: string
updatedAt: string
}
let projects = $state<Project[]>([])
let isLoading = $state(true)
let error = $state('')
let total = $state(0)
const columns = [
{
key: 'title',
label: 'Title',
width: '40%',
component: ProjectTitleCell
},
{
key: 'year',
label: 'Year',
width: '15%'
},
{
key: 'client',
label: 'Client',
width: '30%',
render: (project: Project) => project.client || '—'
},
{
key: 'status',
label: 'Status',
width: '15%',
render: (project: Project) => {
return project.status === 'published' ? '🟢' : '⚪'
}
}
]
let showDeleteModal = $state(false)
let projectToDelete = $state<Project | null>(null)
let activeDropdown = $state<number | null>(null)
onMount(async () => {
await loadProjects()
// Close dropdown when clicking outside
document.addEventListener('click', handleOutsideClick)
return () => document.removeEventListener('click', handleOutsideClick)
})
function handleOutsideClick(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.dropdown-container')) {
activeDropdown = null
}
}
async function loadProjects() {
try {
const auth = localStorage.getItem('admin_auth')
@ -76,7 +61,6 @@
const data = await response.json()
projects = data.projects
total = data.pagination?.total || projects.length
} catch (err) {
error = 'Failed to load projects'
console.error(err)
@ -85,12 +69,79 @@
}
}
function handleRowClick(project: Project) {
goto(`/admin/projects/${project.id}/edit`)
function handleToggleDropdown(event: CustomEvent<{ projectId: number; event: MouseEvent }>) {
event.detail.event.stopPropagation()
activeDropdown = activeDropdown === event.detail.projectId ? null : event.detail.projectId
}
function handleEdit(event: CustomEvent<{ project: Project; event: MouseEvent }>) {
event.detail.event.stopPropagation()
goto(`/admin/projects/${event.detail.project.id}/edit`)
}
async function handleTogglePublish(event: CustomEvent<{ project: Project; event: MouseEvent }>) {
event.detail.event.stopPropagation()
activeDropdown = null
const project = event.detail.project
try {
const auth = localStorage.getItem('admin_auth')
const newStatus = project.status === 'published' ? 'draft' : 'published'
const response = await fetch(`/api/projects/${project.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`
},
body: JSON.stringify({ status: newStatus })
})
if (response.ok) {
await loadProjects()
}
} catch (err) {
console.error('Failed to update project status:', err)
}
}
function handleDelete(event: CustomEvent<{ project: Project; event: MouseEvent }>) {
event.detail.event.stopPropagation()
activeDropdown = null
projectToDelete = event.detail.project
showDeleteModal = true
}
async function confirmDelete() {
if (!projectToDelete) return
try {
const auth = localStorage.getItem('admin_auth')
const response = await fetch(`/api/projects/${projectToDelete.id}`, {
method: 'DELETE',
headers: { Authorization: `Basic ${auth}` }
})
if (response.ok) {
await loadProjects()
}
} catch (err) {
console.error('Failed to delete project:', err)
} finally {
showDeleteModal = false
projectToDelete = null
}
}
function cancelDelete() {
showDeleteModal = false
projectToDelete = null
}
</script>
<Page>
<AdminPage>
<header slot="header">
<h1>Projects</h1>
<div class="header-actions">
@ -100,23 +151,39 @@
{#if error}
<div class="error">{error}</div>
{:else}
<div class="projects-stats">
<div class="stat">
<span class="stat-value">{total}</span>
<span class="stat-label">Total projects</span>
</div>
{:else if isLoading}
<div class="loading">
<div class="spinner"></div>
<p>Loading projects...</p>
</div>
{:else if projects.length === 0}
<div class="empty-state">
<p>No projects found. Create your first project!</p>
</div>
{:else}
<div class="projects-list">
{#each projects as project}
<ProjectListItem
{project}
isDropdownActive={activeDropdown === project.id}
ontoggleDropdown={handleToggleDropdown}
onedit={handleEdit}
ontogglePublish={handleTogglePublish}
ondelete={handleDelete}
/>
{/each}
</div>
<DataTable
data={projects}
{columns}
loading={isLoading}
emptyMessage="No projects found. Create your first project!"
onRowClick={handleRowClick}
/>
{/if}
</Page>
</AdminPage>
{#if showDeleteModal && projectToDelete}
<DeleteConfirmationModal
title="Delete project?"
message={`Are you sure you want to delete "${projectToDelete.title}"? This action cannot be undone.`}
onconfirm={confirmDelete}
oncancel={cancelDelete}
/>
{/if}
<style lang="scss">
header {
@ -146,6 +213,8 @@
font-size: 0.925rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
transition: all 0.2s ease;
border: none;
cursor: pointer;
&.btn-primary {
background-color: $grey-10;
@ -164,28 +233,46 @@
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.projects-stats {
display: flex;
gap: $unit-4x;
margin-bottom: $unit-4x;
.loading {
padding: $unit-8x;
text-align: center;
color: $grey-40;
.stat {
display: flex;
flex-direction: column;
gap: $unit-half;
.spinner {
width: 32px;
height: 32px;
border: 3px solid $grey-80;
border-top-color: $primary-color;
border-radius: 50%;
margin: 0 auto $unit-2x;
animation: spin 0.8s linear infinite;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: $grey-10;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
.stat-label {
font-size: 0.875rem;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
p {
margin: 0;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-state {
padding: $unit-8x;
text-align: center;
color: $grey-40;
p {
margin: 0;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
}
.projects-list {
display: flex;
flex-direction: column;
}
</style>

View file

@ -2,91 +2,15 @@
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { z } from 'zod'
import AdminSegmentedControl from '$lib/components/admin/AdminSegmentedControl.svelte'
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
import Editor from '$lib/components/admin/Editor.svelte'
import ProjectForm from '$lib/components/admin/ProjectForm.svelte'
import type { Project } from '$lib/types/project'
// Zod schema for project validation
const projectSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
year: z
.number()
.min(1990)
.max(new Date().getFullYear() + 1),
client: z.string().optional(),
externalUrl: z.string().url().optional().or(z.literal('')),
backgroundColor: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/)
.optional()
.or(z.literal('')),
highlightColor: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/)
.optional()
.or(z.literal('')),
status: z.enum(['draft', 'published'])
})
interface Project {
id: number
slug: string
title: string
subtitle: string | null
description: string | null
year: number
client: string | null
role: string | null
technologies: string[] | null
featuredImage: string | null
gallery: any[] | null
externalUrl: string | null
caseStudyContent: any | null
backgroundColor: string | null
highlightColor: string | null
displayOrder: number
status: string
}
// State
let project = $state<Project | null>(null)
let isLoading = $state(true)
let isSaving = $state(false)
let error = $state('')
let successMessage = $state('')
let activeTab = $state('metadata')
let validationErrors = $state<Record<string, string>>({})
let showPublishMenu = $state(false)
// Form fields as individual state variables for reactivity
let title = $state('')
let subtitle = $state('') // Hidden but kept for backward compatibility
let description = $state('')
let year = $state(new Date().getFullYear())
let client = $state('')
let role = $state('') // Hidden but kept for backward compatibility
let technologies = $state('') // Hidden but kept for backward compatibility
let externalUrl = $state('')
let backgroundColor = $state('')
let highlightColor = $state('')
let status = $state<'draft' | 'published'>('draft')
let caseStudyContent = $state<any>({
type: 'doc',
content: [{ type: 'paragraph' }]
})
// Ref to the editor component
let editorRef: any
const projectId = $derived($page.params.id)
const tabOptions = [
{ value: 'metadata', label: 'Metadata' },
{ value: 'case-study', label: 'Case Study' }
]
onMount(async () => {
await loadProject()
})
@ -109,23 +33,6 @@
const data = await response.json()
project = data
// Populate form fields
title = data.title || ''
subtitle = data.subtitle || ''
description = data.description || ''
year = data.year || new Date().getFullYear()
client = data.client || ''
role = data.role || ''
technologies = Array.isArray(data.technologies) ? data.technologies.join(', ') : ''
externalUrl = data.externalUrl || ''
backgroundColor = data.backgroundColor || ''
highlightColor = data.highlightColor || ''
status = data.status || 'draft'
if (data.caseStudyContent) {
caseStudyContent = data.caseStudyContent
}
} catch (err) {
error = 'Failed to load project'
console.error(err)
@ -133,521 +40,19 @@
isLoading = false
}
}
function validateForm() {
try {
projectSchema.parse({
title,
description: description || undefined,
year,
client: client || undefined,
externalUrl: externalUrl || undefined,
backgroundColor: backgroundColor || undefined,
highlightColor: highlightColor || undefined,
status
})
validationErrors = {}
return true
} catch (err) {
if (err instanceof z.ZodError) {
const errors: Record<string, string> = {}
err.errors.forEach((e) => {
if (e.path[0]) {
errors[e.path[0].toString()] = e.message
}
})
validationErrors = errors
}
return false
}
}
function handleEditorChange(content: any) {
caseStudyContent = content
}
async function handleSave() {
// Check if we're on the case study tab and should save editor content
if (activeTab === 'case-study' && editorRef) {
const editorData = await editorRef.save()
if (editorData) {
caseStudyContent = editorData
}
}
if (!validateForm()) {
error = 'Please fix the validation errors'
return
}
try {
isSaving = true
error = ''
successMessage = ''
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
const response = await fetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title,
subtitle,
description,
year,
client,
role,
technologies: technologies
.split(',')
.map((t) => t.trim())
.filter(Boolean),
externalUrl,
backgroundColor,
highlightColor,
status,
caseStudyContent:
caseStudyContent && caseStudyContent.content && caseStudyContent.content.length > 0
? caseStudyContent
: null
})
})
if (!response.ok) {
throw new Error('Failed to save project')
}
successMessage = 'Project saved successfully!'
setTimeout(() => {
successMessage = ''
}, 3000)
} catch (err) {
error = 'Failed to save project'
console.error(err)
} finally {
isSaving = false
}
}
async function handlePublish() {
status = 'published'
await handleSave()
if (!error) {
await loadProject()
}
showPublishMenu = false
}
async function handleUnpublish() {
status = 'draft'
await handleSave()
if (!error) {
await loadProject()
}
showPublishMenu = false
}
function togglePublishMenu() {
showPublishMenu = !showPublishMenu
}
// Close menu when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.save-actions')) {
showPublishMenu = false
}
}
// Check for unsaved changes
async function checkForUnsavedChanges() {
if (editorRef && typeof editorRef.getIsDirty === 'function') {
const isDirty = editorRef.getIsDirty()
if (isDirty) {
return confirm('You have unsaved changes. Are you sure you want to leave?')
}
}
return true
}
$effect(() => {
if (showPublishMenu) {
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
}
}
})
</script>
<div class="admin-container">
{#if isLoading}
<div class="loading">Loading project...</div>
{:else if !project}
<div class="error">Project not found</div>
{:else}
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if successMessage}
<div class="success-message">{successMessage}</div>
{/if}
<div class="tab-panels">
<!-- Metadata Panel -->
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
<div class="controls-bar">
<AdminSegmentedControl
options={tabOptions}
value={activeTab}
onChange={(value) => (activeTab = value)}
/>
<div class="save-actions">
<button onclick={handleSave} disabled={isSaving} class="btn btn-primary save-button">
{isSaving ? 'Saving...' : status === 'published' ? 'Save' : 'Save Draft'}
</button>
<button
class="btn btn-primary chevron-button"
class:active={showPublishMenu}
onclick={togglePublishMenu}
disabled={isSaving}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if showPublishMenu}
<div class="publish-menu">
{#if status === 'published'}
<button onclick={handleUnpublish} class="menu-item"> Unpublish </button>
{:else}
<button onclick={handlePublish} class="menu-item"> Publish </button>
{/if}
</div>
{/if}
</div>
</div>
<div class="form-content">
<form
onsubmit={(e) => {
e.preventDefault()
handleSave()
}}
>
<div class="form-section">
<FormFieldWrapper label="Title" required error={validationErrors.title}>
<input type="text" bind:value={title} required placeholder="Project title" />
</FormFieldWrapper>
<FormFieldWrapper label="Description" error={validationErrors.description}>
<textarea
bind:value={description}
rows="3"
placeholder="Short description for project cards"
/>
</FormFieldWrapper>
<div class="form-row">
<FormFieldWrapper label="Year" required error={validationErrors.year}>
<input
type="number"
bind:value={year}
required
min="1990"
max={new Date().getFullYear() + 1}
/>
</FormFieldWrapper>
<FormFieldWrapper label="Client" error={validationErrors.client}>
<input type="text" bind:value={client} placeholder="Client or company name" />
</FormFieldWrapper>
</div>
<FormFieldWrapper label="External URL" error={validationErrors.externalUrl}>
<input type="url" bind:value={externalUrl} placeholder="https://example.com" />
</FormFieldWrapper>
</div>
<div class="form-section">
<h2>Styling</h2>
<div class="form-row">
<FormFieldWrapper
label="Background Color"
helpText="Hex color for project card"
error={validationErrors.backgroundColor}
>
<div class="color-input-wrapper">
<input
type="text"
bind:value={backgroundColor}
placeholder="#FFFFFF"
pattern="^#[0-9A-Fa-f]{6}$"
/>
{#if backgroundColor}
<div class="color-preview" style="background-color: {backgroundColor}"></div>
{/if}
</div>
</FormFieldWrapper>
<FormFieldWrapper
label="Highlight Color"
helpText="Accent color for the project"
error={validationErrors.highlightColor}
>
<div class="color-input-wrapper">
<input
type="text"
bind:value={highlightColor}
placeholder="#000000"
pattern="^#[0-9A-Fa-f]{6}$"
/>
{#if highlightColor}
<div class="color-preview" style="background-color: {highlightColor}"></div>
{/if}
</div>
</FormFieldWrapper>
</div>
</div>
</form>
</div>
</div>
<!-- Case Study Panel -->
<div class="panel case-study-wrapper" class:active={activeTab === 'case-study'}>
<div class="controls-bar">
<AdminSegmentedControl
options={tabOptions}
value={activeTab}
onChange={(value) => (activeTab = value)}
/>
<div class="save-actions">
<button onclick={handleSave} disabled={isSaving} class="btn btn-primary save-button">
{isSaving ? 'Saving...' : status === 'published' ? 'Save' : 'Save Draft'}
</button>
<button
class="btn btn-primary chevron-button"
class:active={showPublishMenu}
onclick={togglePublishMenu}
disabled={isSaving}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if showPublishMenu}
<div class="publish-menu">
{#if status === 'published'}
<button onclick={handleUnpublish} class="menu-item"> Unpublish </button>
{:else}
<button onclick={handlePublish} class="menu-item"> Publish </button>
{/if}
</div>
{/if}
</div>
</div>
<div class="editor-content">
<Editor
bind:this={editorRef}
bind:data={caseStudyContent}
onChange={handleEditorChange}
placeholder="Write your case study here..."
minHeight={400}
autofocus={false}
class="case-study-editor"
/>
</div>
</div>
</div>
{/if}
</div>
{#if isLoading}
<div class="loading">Loading project...</div>
{:else if error}
<div class="error">{error}</div>
{:else if !project}
<div class="error">Project not found</div>
{:else}
<ProjectForm {project} mode="edit" />
{/if}
<style lang="scss">
.admin-container {
max-width: 900px;
margin: 0 auto;
padding: 0 $unit-4x $unit-4x;
@include breakpoint('phone') {
padding: 0 $unit-2x $unit-2x;
}
}
.controls-bar {
display: flex;
justify-content: center;
align-items: center;
gap: $unit-2x;
position: relative;
padding: 0 $unit-4x $unit-2x;
border-bottom: 1px solid $grey-80;
margin-bottom: 0;
margin-top: -$unit-2x;
@include breakpoint('phone') {
padding: 0 $unit-3x $unit-2x;
}
}
.save-actions {
position: relative;
display: flex;
margin-left: auto;
}
.btn {
padding: $unit $unit-3x;
border-radius: 50px;
text-decoration: none;
font-size: 0.925rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
transition: all 0.2s ease;
border: none;
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.btn-primary {
background-color: $grey-10;
color: white;
&:hover:not(:disabled) {
background-color: $grey-20;
}
}
&.btn-secondary {
background-color: $grey-95;
color: $grey-20;
&:hover:not(:disabled) {
background-color: $grey-90;
color: $grey-10;
}
}
}
.save-button {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-right: $unit-2x;
}
.chevron-button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding: $unit $unit;
border-left: 1px solid rgba(255, 255, 255, 0.2);
&.active {
background-color: $grey-20;
}
svg {
display: block;
transition: transform 0.2s ease;
}
&.active svg {
transform: rotate(180deg);
}
}
.publish-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: $unit;
background: white;
border-radius: $unit;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
overflow: hidden;
min-width: 120px;
z-index: 100;
.menu-item {
display: block;
width: 100%;
padding: $unit-2x $unit-3x;
text-align: left;
background: none;
border: none;
font-size: 0.925rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
color: $grey-10;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: $grey-95;
}
}
}
.tab-panels {
position: relative;
.panel {
display: none;
box-sizing: border-box;
&.active {
display: block;
}
}
}
.content-wrapper {
background: white;
border-radius: $unit-2x;
padding: $unit-4x 0;
max-width: 700px;
margin: 0 auto;
@include breakpoint('phone') {
padding: $unit-3x 0;
}
}
.loading,
.error {
text-align: center;
@ -659,143 +64,4 @@
.error {
color: #d33;
}
.error-message,
.success-message {
padding: $unit-3x;
border-radius: $unit;
margin-bottom: $unit-4x;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.error-message {
background-color: #fee;
color: #d33;
}
.success-message {
background-color: #efe;
color: #363;
}
.form-section {
margin-bottom: $unit-6x;
&:last-child {
margin-bottom: 0;
}
h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 $unit-3x;
color: $grey-10;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $unit-2x;
padding-bottom: $unit-3x;
@include breakpoint('phone') {
grid-template-columns: 1fr;
}
:global(.form-field) {
margin-bottom: 0;
}
}
input[type='text'],
input[type='url'],
input[type='number'],
textarea {
width: 100%;
box-sizing: border-box;
padding: calc($unit * 1.5);
border: 1px solid $grey-80;
border-radius: $unit;
font-size: 1rem;
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
transition: border-color 0.2s ease;
background-color: white;
color: #333;
&:focus {
outline: none;
border-color: $grey-40;
}
&::placeholder {
color: #999;
}
}
textarea {
resize: vertical;
min-height: 80px;
}
.color-input-wrapper {
display: flex;
align-items: center;
gap: $unit-2x;
input {
flex: 1;
}
.color-preview {
width: 40px;
height: 40px;
border-radius: $unit;
border: 1px solid $grey-80;
flex-shrink: 0;
}
}
.case-study-wrapper {
background: white;
border-radius: $unit-2x;
padding: $unit-4x 0 0;
min-height: 80vh;
max-width: 700px;
margin: 0 auto;
display: flex;
flex-direction: column;
overflow: hidden;
@include breakpoint('phone') {
padding: $unit-3x 0 0;
height: 600px;
}
}
.form-content {
padding: $unit-4x;
@include breakpoint('phone') {
padding: $unit-3x;
}
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
/* The editor component will handle its own padding and scrolling */
:global(.case-study-editor) {
flex: 1;
overflow: auto;
}
}
</style>
</style>

View file

@ -0,0 +1,5 @@
<script lang="ts">
import ProjectForm from '$lib/components/admin/ProjectForm.svelte'
</script>
<ProjectForm mode="create" />

View file

@ -1,5 +1,5 @@
<script lang="ts">
import Page from '$lib/components/Page.svelte'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Editor from '$lib/components/admin/Editor.svelte'
import type { JSONContent } from '@tiptap/core'
@ -108,7 +108,7 @@
})
</script>
<Page>
<AdminPage>
<header slot="header">
<h1>Upload Test</h1>
<a href="/admin/projects" class="back-link">← Back to Projects</a>
@ -179,7 +179,7 @@
<pre>{JSON.stringify(testContent, null, 2)}</pre>
</div>
</div>
</Page>
</AdminPage>
<style lang="scss">
header {

View file

@ -85,6 +85,7 @@ export const POST: RequestHandler = async (event) => {
role: body.role,
technologies: body.technologies || [],
featuredImage: body.featuredImage,
logoUrl: body.logoUrl,
gallery: body.gallery || [],
externalUrl: body.externalUrl,
caseStudyContent: body.caseStudyContent,

View file

@ -78,6 +78,7 @@ export const PUT: RequestHandler = async (event) => {
role: body.role ?? existing.role,
technologies: body.technologies ?? existing.technologies,
featuredImage: body.featuredImage ?? existing.featuredImage,
logoUrl: body.logoUrl ?? existing.logoUrl,
gallery: body.gallery ?? existing.gallery,
externalUrl: body.externalUrl ?? existing.externalUrl,
caseStudyContent: body.caseStudyContent ?? existing.caseStudyContent,

View file

@ -18,7 +18,7 @@ const config = {
$illos: 'src/assets/illos',
$components: 'src/lib/components',
$utils: 'src/lib/utils',
$styles: 'src/styles'
$styles: 'src/assets/styles'
}
}
}

View file

@ -4,6 +4,11 @@ import autoprefixer from 'autoprefixer'
import svg from '@poppanator/sveltekit-svg'
export default defineConfig({
server: {
watch: {
usePolling: true
}
},
plugins: [
sveltekit(),
svg({
@ -59,7 +64,8 @@ export default defineConfig({
@import './src/assets/styles/fonts.scss';
@import './src/assets/styles/themes.scss';
@import './src/assets/styles/globals.scss';
`
`,
api: 'modern-compiler'
}
},
postcss: {