From 2251bd276cc53f34ad0dc7b3a0b3c439078b2890 Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 6 Jan 2026 19:39:13 -0600 Subject: [PATCH] refactor: implement full projects CRUD, move icons onto tag schema - Remove priority field and sorting, switch to updated_at DESC - Add icon field to tags table - Split project description into name and short_description - Implement full CRUD for projects (create, update, delete) - Add admin stats endpoint (project counts by status) --- ...f0a6eb8589bb6dff47afe1554850c1bead57.json} | 5 +- ...56ad56c0e14144b2e0a6d8b1fcc29196dcca0.json | 38 + ...6a60c70a942d8d29af1439c30a5b04ad4fc6b.json | 113 +++ ...dd6c99e08b19add7b9872068cc960afcd7c8.json} | 12 +- ...b9c331ac7914b527ca6a98865fe4fb8a793c.json} | 34 +- ...2f91c2bb0a2cd2025366ac2af0cb0717e7d5.json} | 12 +- ...90178894997da3b13be0323c6172ecb419db.json} | 34 +- ...d4d8a57ac35b7b04be94027a33b4bf845195.json} | 13 +- ...16645776380fda0f84d2f53f408b3bc5ff035.json | 20 + ...4c28aeceba2cc20b1c16cbdfb8c7ea4b23c0.json} | 14 +- ...9193ded06c170ce5646cbdc9fbeb3f842869.json} | 12 +- ...a8356f6ef20fa5aae9c6126e4944dffbd5ee0.json | 92 ++ ...0f712f5bd2373a5be11cad1714daf357c0f34.json | 94 ++ ...185e8de55044321c5f85883610e40e51d286.json} | 13 +- ...ba06cc3212ffffb8520fc7dbbcc8b60ada314.json | 14 + ...f0c8cf0885e431aea0cd349998de717c5e776.json | 112 +++ ...7c236aa9113b4d95aee0b1318e5a9f1bb4f1.json} | 14 +- ...83fc4a68d4917721cbca149bc841b39a9481.json} | 12 +- ...91103d04a7ff27b27d7d197fbd53593c06b74.json | 94 ++ ...0260107002857_refactor_projects_schema.sql | 9 + .../20260107004613_move_icons_to_tags.sql | 5 + src/bin/seed.rs | 65 +- src/db.rs | 429 ++++++++- src/main.rs | 534 ++++++++++- web/src/lib/admin-types.ts | 24 +- web/src/lib/api.server.ts | 10 +- web/src/lib/api.ts | 872 +++--------------- web/src/lib/components/ProjectCard.svelte | 56 +- .../lib/components/admin/ProjectForm.svelte | 73 +- web/src/routes/+page.server.ts | 17 +- web/src/routes/admin/projects/+page.svelte | 12 +- 31 files changed, 1864 insertions(+), 994 deletions(-) rename .sqlx/{query-7f51a4655f0c523554b1e57fd70cfd7d8930909c64495b2ae1c1f44f905ce44e.json => query-1a6f4d858c200757c1d25c22279ef0a6eb8589bb6dff47afe1554850c1bead57.json} (55%) create mode 100644 .sqlx/query-1dce5dfc93dcb882e6bdeacbe4156ad56c0e14144b2e0a6d8b1fcc29196dcca0.json create mode 100644 .sqlx/query-39f5640a8e812c05d62a0c5ab696a60c70a942d8d29af1439c30a5b04ad4fc6b.json rename .sqlx/{query-0851e9cb9d471e713b0af09fc81c2b997f96562334359531d163bfeefe184a68.json => query-3f662485c3beb37febc746c49a9bdd6c99e08b19add7b9872068cc960afcd7c8.json} (69%) rename .sqlx/{query-c7dd8cd6c4e50e1e0f327c040a9497d8427848e8754778fbaf5f22e656e04e12.json => query-42723e40f3adff9eb24a701347c5b9c331ac7914b527ca6a98865fe4fb8a793c.json} (69%) rename .sqlx/{query-120b55f03836eb7a3f2c3569bc736a07d08b0e44a129e209cdfead695453245e.json => query-46df2723810fa84cd1c2f228b5052f91c2bb0a2cd2025366ac2af0cb0717e7d5.json} (63%) rename .sqlx/{query-8adc48c833126d2cd690612a83c1637347e8bdfd230bf46c60ceef8fa096391e.json => query-5514e52a1311c6e10f84d60f906c90178894997da3b13be0323c6172ecb419db.json} (72%) rename .sqlx/{query-cb7b987b7053f2b391fef1ef6d54e5adeb648a7bcc5cf92b7bfb7ba2b6efc6e5.json => query-7221aa294be3a27520a25739ebf5d4d8a57ac35b7b04be94027a33b4bf845195.json} (71%) create mode 100644 .sqlx/query-76293a500bb01e184759b8b2f1216645776380fda0f84d2f53f408b3bc5ff035.json rename .sqlx/{query-5fb630ce115b4f9cdc7325a20cedb6a527fad52bc964e9904cfa965c7cbd7d12.json => query-82c27809a4e88b594c41b65c573c4c28aeceba2cc20b1c16cbdfb8c7ea4b23c0.json} (61%) rename .sqlx/{query-19003c2f88aa2aeeeb2b6288eb09268d143568ef811d3d4e17fcef6e42f26985.json => query-8cbc29e0bd4d323907b8d47196ef9193ded06c170ce5646cbdc9fbeb3f842869.json} (69%) create mode 100644 .sqlx/query-960a24b5174e421d57f21944632a8356f6ef20fa5aae9c6126e4944dffbd5ee0.json create mode 100644 .sqlx/query-990fae3e6568e19f18784fb562b0f712f5bd2373a5be11cad1714daf357c0f34.json rename .sqlx/{query-5cd22d353f4c1ba8e761ab97bedb70018494868bd177d77c4262255e2bffddd7.json => query-99a63a1f9b888490418aa4e81f5c185e8de55044321c5f85883610e40e51d286.json} (66%) create mode 100644 .sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json create mode 100644 .sqlx/query-ad86ca2613973d89e49b3bea1b3f0c8cf0885e431aea0cd349998de717c5e776.json rename .sqlx/{query-e41f28efcfaf2efee773dc6e7261d2f63eb81886f7f73e989807d6e4d64a01ea.json => query-b7a265d9e8e14728f9e6b469d52c7c236aa9113b4d95aee0b1318e5a9f1bb4f1.json} (62%) rename .sqlx/{query-9dad9afb86f77510f5ec3eb7e1487945b7052370f0fa9d6d5709978d3fbe76cd.json => query-d50291c2483aa95fa6a734d69dcd83fc4a68d4917721cbca149bc841b39a9481.json} (68%) create mode 100644 .sqlx/query-e3c4842a1151f51f3871212c3e591103d04a7ff27b27d7d197fbd53593c06b74.json create mode 100644 migrations/20260107002857_refactor_projects_schema.sql create mode 100644 migrations/20260107004613_move_icons_to_tags.sql diff --git a/.sqlx/query-7f51a4655f0c523554b1e57fd70cfd7d8930909c64495b2ae1c1f44f905ce44e.json b/.sqlx/query-1a6f4d858c200757c1d25c22279ef0a6eb8589bb6dff47afe1554850c1bead57.json similarity index 55% rename from .sqlx/query-7f51a4655f0c523554b1e57fd70cfd7d8930909c64495b2ae1c1f44f905ce44e.json rename to .sqlx/query-1a6f4d858c200757c1d25c22279ef0a6eb8589bb6dff47afe1554850c1bead57.json index 1f4fc5a..979d26e 100644 --- a/.sqlx/query-7f51a4655f0c523554b1e57fd70cfd7d8930909c64495b2ae1c1f44f905ce44e.json +++ b/.sqlx/query-1a6f4d858c200757c1d25c22279ef0a6eb8589bb6dff47afe1554850c1bead57.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO tags (slug, name)\n VALUES ($1, $2)\n RETURNING id\n ", + "query": "\n INSERT INTO tags (slug, name, icon)\n VALUES ($1, $2, $3)\n RETURNING id\n ", "describe": { "columns": [ { @@ -11,6 +11,7 @@ ], "parameters": { "Left": [ + "Text", "Text", "Text" ] @@ -19,5 +20,5 @@ false ] }, - "hash": "7f51a4655f0c523554b1e57fd70cfd7d8930909c64495b2ae1c1f44f905ce44e" + "hash": "1a6f4d858c200757c1d25c22279ef0a6eb8589bb6dff47afe1554850c1bead57" } diff --git a/.sqlx/query-1dce5dfc93dcb882e6bdeacbe4156ad56c0e14144b2e0a6d8b1fcc29196dcca0.json b/.sqlx/query-1dce5dfc93dcb882e6bdeacbe4156ad56c0e14144b2e0a6d8b1fcc29196dcca0.json new file mode 100644 index 0000000..29661cb --- /dev/null +++ b/.sqlx/query-1dce5dfc93dcb882e6bdeacbe4156ad56c0e14144b2e0a6d8b1fcc29196dcca0.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n status as \"status!: ProjectStatus\",\n COUNT(*)::int as \"count!\"\n FROM projects\n GROUP BY status\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status!: ProjectStatus", + "type_info": { + "Custom": { + "name": "project_status", + "kind": { + "Enum": [ + "active", + "maintained", + "archived", + "hidden" + ] + } + } + } + }, + { + "ordinal": 1, + "name": "count!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + null + ] + }, + "hash": "1dce5dfc93dcb882e6bdeacbe4156ad56c0e14144b2e0a6d8b1fcc29196dcca0" +} diff --git a/.sqlx/query-39f5640a8e812c05d62a0c5ab696a60c70a942d8d29af1439c30a5b04ad4fc6b.json b/.sqlx/query-39f5640a8e812c05d62a0c5ab696a60c70a942d8d29af1439c30a5b04ad4fc6b.json new file mode 100644 index 0000000..7df7b58 --- /dev/null +++ b/.sqlx/query-39f5640a8e812c05d62a0c5ab696a60c70a942d8d29af1439c30a5b04ad4fc6b.json @@ -0,0 +1,113 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE projects\n SET slug = $2, name = $3, short_description = $4, description = $5, \n status = $6, github_repo = $7, demo_url = $8\n WHERE id = $1\n RETURNING id, slug, name, short_description, description, status as \"status: ProjectStatus\", \n github_repo, demo_url, last_github_activity, created_at, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "short_description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status: ProjectStatus", + "type_info": { + "Custom": { + "name": "project_status", + "kind": { + "Enum": [ + "active", + "maintained", + "archived", + "hidden" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "github_repo", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "demo_url", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "last_github_activity", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Text", + { + "Custom": { + "name": "project_status", + "kind": { + "Enum": [ + "active", + "maintained", + "archived", + "hidden" + ] + } + } + }, + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "39f5640a8e812c05d62a0c5ab696a60c70a942d8d29af1439c30a5b04ad4fc6b" +} diff --git a/.sqlx/query-0851e9cb9d471e713b0af09fc81c2b997f96562334359531d163bfeefe184a68.json b/.sqlx/query-3f662485c3beb37febc746c49a9bdd6c99e08b19add7b9872068cc960afcd7c8.json similarity index 69% rename from .sqlx/query-0851e9cb9d471e713b0af09fc81c2b997f96562334359531d163bfeefe184a68.json rename to .sqlx/query-3f662485c3beb37febc746c49a9bdd6c99e08b19add7b9872068cc960afcd7c8.json index 8874408..95e5ad6 100644 --- a/.sqlx/query-0851e9cb9d471e713b0af09fc81c2b997f96562334359531d163bfeefe184a68.json +++ b/.sqlx/query-3f662485c3beb37febc746c49a9bdd6c99e08b19add7b9872068cc960afcd7c8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT t.id, t.slug, t.name, t.color, t.created_at\n FROM tags t\n JOIN project_tags pt ON t.id = pt.tag_id\n WHERE pt.project_id = $1\n ORDER BY t.name ASC\n ", + "query": "\n SELECT id, slug, name, icon, color, created_at\n FROM tags\n WHERE id = $1\n ", "describe": { "columns": [ { @@ -20,11 +20,16 @@ }, { "ordinal": 3, + "name": "icon", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "color", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } @@ -39,8 +44,9 @@ false, false, true, + true, false ] }, - "hash": "0851e9cb9d471e713b0af09fc81c2b997f96562334359531d163bfeefe184a68" + "hash": "3f662485c3beb37febc746c49a9bdd6c99e08b19add7b9872068cc960afcd7c8" } diff --git a/.sqlx/query-c7dd8cd6c4e50e1e0f327c040a9497d8427848e8754778fbaf5f22e656e04e12.json b/.sqlx/query-42723e40f3adff9eb24a701347c5b9c331ac7914b527ca6a98865fe4fb8a793c.json similarity index 69% rename from .sqlx/query-c7dd8cd6c4e50e1e0f327c040a9497d8427848e8754778fbaf5f22e656e04e12.json rename to .sqlx/query-42723e40f3adff9eb24a701347c5b9c331ac7914b527ca6a98865fe4fb8a793c.json index ad5c45d..621b5cf 100644 --- a/.sqlx/query-c7dd8cd6c4e50e1e0f327c040a9497d8427848e8754778fbaf5f22e656e04e12.json +++ b/.sqlx/query-42723e40f3adff9eb24a701347c5b9c331ac7914b527ca6a98865fe4fb8a793c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT \n p.id, \n p.slug, \n p.title, \n p.description, \n p.status as \"status: ProjectStatus\", \n p.github_repo, \n p.demo_url, \n p.priority, \n p.icon, \n p.last_github_activity, \n p.created_at, \n p.updated_at\n FROM projects p\n JOIN project_tags pt ON p.id = pt.project_id\n WHERE pt.tag_id = $1\n ORDER BY p.priority DESC, p.created_at DESC\n ", + "query": "\n SELECT \n p.id, \n p.slug, \n p.name,\n p.short_description,\n p.description, \n p.status as \"status: ProjectStatus\", \n p.github_repo, \n p.demo_url, \n p.last_github_activity, \n p.created_at, \n p.updated_at\n FROM projects p\n JOIN project_tags pt ON p.id = pt.project_id\n WHERE pt.tag_id = $1\n ORDER BY p.updated_at DESC\n ", "describe": { "columns": [ { @@ -15,16 +15,21 @@ }, { "ordinal": 2, - "name": "title", + "name": "name", "type_info": "Text" }, { "ordinal": 3, - "name": "description", + "name": "short_description", "type_info": "Text" }, { "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, "name": "status: ProjectStatus", "type_info": { "Custom": { @@ -41,37 +46,27 @@ } }, { - "ordinal": 5, + "ordinal": 6, "name": "github_repo", "type_info": "Text" }, { - "ordinal": 6, + "ordinal": 7, "name": "demo_url", "type_info": "Text" }, - { - "ordinal": 7, - "name": "priority", - "type_info": "Int4" - }, { "ordinal": 8, - "name": "icon", - "type_info": "Text" - }, - { - "ordinal": 9, "name": "last_github_activity", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 11, + "ordinal": 10, "name": "updated_at", "type_info": "Timestamptz" } @@ -87,14 +82,13 @@ false, false, false, - true, - true, false, true, true, + true, false, false ] }, - "hash": "c7dd8cd6c4e50e1e0f327c040a9497d8427848e8754778fbaf5f22e656e04e12" + "hash": "42723e40f3adff9eb24a701347c5b9c331ac7914b527ca6a98865fe4fb8a793c" } diff --git a/.sqlx/query-120b55f03836eb7a3f2c3569bc736a07d08b0e44a129e209cdfead695453245e.json b/.sqlx/query-46df2723810fa84cd1c2f228b5052f91c2bb0a2cd2025366ac2af0cb0717e7d5.json similarity index 63% rename from .sqlx/query-120b55f03836eb7a3f2c3569bc736a07d08b0e44a129e209cdfead695453245e.json rename to .sqlx/query-46df2723810fa84cd1c2f228b5052f91c2bb0a2cd2025366ac2af0cb0717e7d5.json index f597701..8c6626a 100644 --- a/.sqlx/query-120b55f03836eb7a3f2c3569bc736a07d08b0e44a129e209cdfead695453245e.json +++ b/.sqlx/query-46df2723810fa84cd1c2f228b5052f91c2bb0a2cd2025366ac2af0cb0717e7d5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, slug, name, color, created_at\n FROM tags\n WHERE id = $1\n ", + "query": "\n SELECT t.id, t.slug, t.name, t.icon, t.color, t.created_at\n FROM tags t\n JOIN project_tags pt ON t.id = pt.tag_id\n WHERE pt.project_id = $1\n ORDER BY t.name ASC\n ", "describe": { "columns": [ { @@ -20,11 +20,16 @@ }, { "ordinal": 3, + "name": "icon", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "color", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } @@ -39,8 +44,9 @@ false, false, true, + true, false ] }, - "hash": "120b55f03836eb7a3f2c3569bc736a07d08b0e44a129e209cdfead695453245e" + "hash": "46df2723810fa84cd1c2f228b5052f91c2bb0a2cd2025366ac2af0cb0717e7d5" } diff --git a/.sqlx/query-8adc48c833126d2cd690612a83c1637347e8bdfd230bf46c60ceef8fa096391e.json b/.sqlx/query-5514e52a1311c6e10f84d60f906c90178894997da3b13be0323c6172ecb419db.json similarity index 72% rename from .sqlx/query-8adc48c833126d2cd690612a83c1637347e8bdfd230bf46c60ceef8fa096391e.json rename to .sqlx/query-5514e52a1311c6e10f84d60f906c90178894997da3b13be0323c6172ecb419db.json index 2b4a494..c3702c4 100644 --- a/.sqlx/query-8adc48c833126d2cd690612a83c1637347e8bdfd230bf46c60ceef8fa096391e.json +++ b/.sqlx/query-5514e52a1311c6e10f84d60f906c90178894997da3b13be0323c6172ecb419db.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT \n id, \n slug, \n title, \n description, \n status as \"status: ProjectStatus\", \n github_repo, \n demo_url, \n priority, \n icon, \n last_github_activity, \n created_at, \n updated_at\n FROM projects\n WHERE status != 'hidden'\n ORDER BY priority DESC, created_at DESC\n ", + "query": "\n SELECT \n id, \n slug, \n name,\n short_description,\n description, \n status as \"status: ProjectStatus\", \n github_repo, \n demo_url, \n last_github_activity, \n created_at, \n updated_at\n FROM projects\n ORDER BY updated_at DESC\n ", "describe": { "columns": [ { @@ -15,16 +15,21 @@ }, { "ordinal": 2, - "name": "title", + "name": "name", "type_info": "Text" }, { "ordinal": 3, - "name": "description", + "name": "short_description", "type_info": "Text" }, { "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, "name": "status: ProjectStatus", "type_info": { "Custom": { @@ -41,37 +46,27 @@ } }, { - "ordinal": 5, + "ordinal": 6, "name": "github_repo", "type_info": "Text" }, { - "ordinal": 6, + "ordinal": 7, "name": "demo_url", "type_info": "Text" }, - { - "ordinal": 7, - "name": "priority", - "type_info": "Int4" - }, { "ordinal": 8, - "name": "icon", - "type_info": "Text" - }, - { - "ordinal": 9, "name": "last_github_activity", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 9, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 11, + "ordinal": 10, "name": "updated_at", "type_info": "Timestamptz" } @@ -85,14 +80,13 @@ false, false, false, - true, - true, false, true, true, + true, false, false ] }, - "hash": "8adc48c833126d2cd690612a83c1637347e8bdfd230bf46c60ceef8fa096391e" + "hash": "5514e52a1311c6e10f84d60f906c90178894997da3b13be0323c6172ecb419db" } diff --git a/.sqlx/query-cb7b987b7053f2b391fef1ef6d54e5adeb648a7bcc5cf92b7bfb7ba2b6efc6e5.json b/.sqlx/query-7221aa294be3a27520a25739ebf5d4d8a57ac35b7b04be94027a33b4bf845195.json similarity index 71% rename from .sqlx/query-cb7b987b7053f2b391fef1ef6d54e5adeb648a7bcc5cf92b7bfb7ba2b6efc6e5.json rename to .sqlx/query-7221aa294be3a27520a25739ebf5d4d8a57ac35b7b04be94027a33b4bf845195.json index 9071c49..847f4d1 100644 --- a/.sqlx/query-cb7b987b7053f2b391fef1ef6d54e5adeb648a7bcc5cf92b7bfb7ba2b6efc6e5.json +++ b/.sqlx/query-7221aa294be3a27520a25739ebf5d4d8a57ac35b7b04be94027a33b4bf845195.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE tags\n SET slug = $2, name = $3, color = $4\n WHERE id = $1\n RETURNING id, slug, name, color, created_at\n ", + "query": "\n UPDATE tags\n SET slug = $2, name = $3, icon = $4, color = $5\n WHERE id = $1\n RETURNING id, slug, name, icon, color, created_at\n ", "describe": { "columns": [ { @@ -20,11 +20,16 @@ }, { "ordinal": 3, + "name": "icon", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "color", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } @@ -34,6 +39,7 @@ "Uuid", "Text", "Text", + "Text", "Varchar" ] }, @@ -42,8 +48,9 @@ false, false, true, + true, false ] }, - "hash": "cb7b987b7053f2b391fef1ef6d54e5adeb648a7bcc5cf92b7bfb7ba2b6efc6e5" + "hash": "7221aa294be3a27520a25739ebf5d4d8a57ac35b7b04be94027a33b4bf845195" } diff --git a/.sqlx/query-76293a500bb01e184759b8b2f1216645776380fda0f84d2f53f408b3bc5ff035.json b/.sqlx/query-76293a500bb01e184759b8b2f1216645776380fda0f84d2f53f408b3bc5ff035.json new file mode 100644 index 0000000..8ac9e19 --- /dev/null +++ b/.sqlx/query-76293a500bb01e184759b8b2f1216645776380fda0f84d2f53f408b3bc5ff035.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*)::int as \"count!\" FROM tags", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "76293a500bb01e184759b8b2f1216645776380fda0f84d2f53f408b3bc5ff035" +} diff --git a/.sqlx/query-5fb630ce115b4f9cdc7325a20cedb6a527fad52bc964e9904cfa965c7cbd7d12.json b/.sqlx/query-82c27809a4e88b594c41b65c573c4c28aeceba2cc20b1c16cbdfb8c7ea4b23c0.json similarity index 61% rename from .sqlx/query-5fb630ce115b4f9cdc7325a20cedb6a527fad52bc964e9904cfa965c7cbd7d12.json rename to .sqlx/query-82c27809a4e88b594c41b65c573c4c28aeceba2cc20b1c16cbdfb8c7ea4b23c0.json index 5e70dee..b3039ee 100644 --- a/.sqlx/query-5fb630ce115b4f9cdc7325a20cedb6a527fad52bc964e9904cfa965c7cbd7d12.json +++ b/.sqlx/query-82c27809a4e88b594c41b65c573c4c28aeceba2cc20b1c16cbdfb8c7ea4b23c0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT \n t.id, \n t.slug, \n t.name,\n t.color,\n t.created_at,\n COUNT(pt.project_id)::int as \"project_count!\"\n FROM tags t\n LEFT JOIN project_tags pt ON t.id = pt.tag_id\n GROUP BY t.id, t.slug, t.name, t.color, t.created_at\n ORDER BY t.name ASC\n ", + "query": "\n SELECT \n t.id, \n t.slug, \n t.name,\n t.icon,\n t.color,\n t.created_at,\n COUNT(pt.project_id)::int as \"project_count!\"\n FROM tags t\n LEFT JOIN project_tags pt ON t.id = pt.tag_id\n GROUP BY t.id, t.slug, t.name, t.icon, t.color, t.created_at\n ORDER BY t.name ASC\n ", "describe": { "columns": [ { @@ -20,16 +20,21 @@ }, { "ordinal": 3, + "name": "icon", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "color", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 5, + "ordinal": 6, "name": "project_count!", "type_info": "Int4" } @@ -42,9 +47,10 @@ false, false, true, + true, false, null ] }, - "hash": "5fb630ce115b4f9cdc7325a20cedb6a527fad52bc964e9904cfa965c7cbd7d12" + "hash": "82c27809a4e88b594c41b65c573c4c28aeceba2cc20b1c16cbdfb8c7ea4b23c0" } diff --git a/.sqlx/query-19003c2f88aa2aeeeb2b6288eb09268d143568ef811d3d4e17fcef6e42f26985.json b/.sqlx/query-8cbc29e0bd4d323907b8d47196ef9193ded06c170ce5646cbdc9fbeb3f842869.json similarity index 69% rename from .sqlx/query-19003c2f88aa2aeeeb2b6288eb09268d143568ef811d3d4e17fcef6e42f26985.json rename to .sqlx/query-8cbc29e0bd4d323907b8d47196ef9193ded06c170ce5646cbdc9fbeb3f842869.json index 200b2af..c02b79f 100644 --- a/.sqlx/query-19003c2f88aa2aeeeb2b6288eb09268d143568ef811d3d4e17fcef6e42f26985.json +++ b/.sqlx/query-8cbc29e0bd4d323907b8d47196ef9193ded06c170ce5646cbdc9fbeb3f842869.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, slug, name, color, created_at\n FROM tags\n WHERE slug = $1\n ", + "query": "\n SELECT id, slug, name, icon, color, created_at\n FROM tags\n WHERE slug = $1\n ", "describe": { "columns": [ { @@ -20,11 +20,16 @@ }, { "ordinal": 3, + "name": "icon", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "color", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } @@ -39,8 +44,9 @@ false, false, true, + true, false ] }, - "hash": "19003c2f88aa2aeeeb2b6288eb09268d143568ef811d3d4e17fcef6e42f26985" + "hash": "8cbc29e0bd4d323907b8d47196ef9193ded06c170ce5646cbdc9fbeb3f842869" } diff --git a/.sqlx/query-960a24b5174e421d57f21944632a8356f6ef20fa5aae9c6126e4944dffbd5ee0.json b/.sqlx/query-960a24b5174e421d57f21944632a8356f6ef20fa5aae9c6126e4944dffbd5ee0.json new file mode 100644 index 0000000..da78df2 --- /dev/null +++ b/.sqlx/query-960a24b5174e421d57f21944632a8356f6ef20fa5aae9c6126e4944dffbd5ee0.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n id, \n slug, \n name,\n short_description,\n description, \n status as \"status: ProjectStatus\", \n github_repo, \n demo_url, \n last_github_activity, \n created_at, \n updated_at\n FROM projects\n WHERE status != 'hidden'\n ORDER BY updated_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "short_description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status: ProjectStatus", + "type_info": { + "Custom": { + "name": "project_status", + "kind": { + "Enum": [ + "active", + "maintained", + "archived", + "hidden" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "github_repo", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "demo_url", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "last_github_activity", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "960a24b5174e421d57f21944632a8356f6ef20fa5aae9c6126e4944dffbd5ee0" +} diff --git a/.sqlx/query-990fae3e6568e19f18784fb562b0f712f5bd2373a5be11cad1714daf357c0f34.json b/.sqlx/query-990fae3e6568e19f18784fb562b0f712f5bd2373a5be11cad1714daf357c0f34.json new file mode 100644 index 0000000..8c996a5 --- /dev/null +++ b/.sqlx/query-990fae3e6568e19f18784fb562b0f712f5bd2373a5be11cad1714daf357c0f34.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n id, \n slug, \n name,\n short_description,\n description, \n status as \"status: ProjectStatus\", \n github_repo, \n demo_url, \n\n last_github_activity, \n created_at, \n updated_at\n FROM projects\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "short_description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status: ProjectStatus", + "type_info": { + "Custom": { + "name": "project_status", + "kind": { + "Enum": [ + "active", + "maintained", + "archived", + "hidden" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "github_repo", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "demo_url", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "last_github_activity", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "990fae3e6568e19f18784fb562b0f712f5bd2373a5be11cad1714daf357c0f34" +} diff --git a/.sqlx/query-5cd22d353f4c1ba8e761ab97bedb70018494868bd177d77c4262255e2bffddd7.json b/.sqlx/query-99a63a1f9b888490418aa4e81f5c185e8de55044321c5f85883610e40e51d286.json similarity index 66% rename from .sqlx/query-5cd22d353f4c1ba8e761ab97bedb70018494868bd177d77c4262255e2bffddd7.json rename to .sqlx/query-99a63a1f9b888490418aa4e81f5c185e8de55044321c5f85883610e40e51d286.json index c9799d6..7c1bd26 100644 --- a/.sqlx/query-5cd22d353f4c1ba8e761ab97bedb70018494868bd177d77c4262255e2bffddd7.json +++ b/.sqlx/query-99a63a1f9b888490418aa4e81f5c185e8de55044321c5f85883610e40e51d286.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO tags (slug, name, color)\n VALUES ($1, $2, $3)\n RETURNING id, slug, name, color, created_at\n ", + "query": "\n INSERT INTO tags (slug, name, icon, color)\n VALUES ($1, $2, $3, $4)\n RETURNING id, slug, name, icon, color, created_at\n ", "describe": { "columns": [ { @@ -20,17 +20,23 @@ }, { "ordinal": 3, + "name": "icon", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "color", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } ], "parameters": { "Left": [ + "Text", "Text", "Text", "Varchar" @@ -41,8 +47,9 @@ false, false, true, + true, false ] }, - "hash": "5cd22d353f4c1ba8e761ab97bedb70018494868bd177d77c4262255e2bffddd7" + "hash": "99a63a1f9b888490418aa4e81f5c185e8de55044321c5f85883610e40e51d286" } diff --git a/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json b/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json new file mode 100644 index 0000000..fd6a664 --- /dev/null +++ b/.sqlx/query-a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM projects WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314" +} diff --git a/.sqlx/query-ad86ca2613973d89e49b3bea1b3f0c8cf0885e431aea0cd349998de717c5e776.json b/.sqlx/query-ad86ca2613973d89e49b3bea1b3f0c8cf0885e431aea0cd349998de717c5e776.json new file mode 100644 index 0000000..b9e410c --- /dev/null +++ b/.sqlx/query-ad86ca2613973d89e49b3bea1b3f0c8cf0885e431aea0cd349998de717c5e776.json @@ -0,0 +1,112 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO projects (slug, name, short_description, description, status, github_repo, demo_url)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, slug, name, short_description, description, status as \"status: ProjectStatus\", \n github_repo, demo_url, last_github_activity, created_at, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "short_description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status: ProjectStatus", + "type_info": { + "Custom": { + "name": "project_status", + "kind": { + "Enum": [ + "active", + "maintained", + "archived", + "hidden" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "github_repo", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "demo_url", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "last_github_activity", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + { + "Custom": { + "name": "project_status", + "kind": { + "Enum": [ + "active", + "maintained", + "archived", + "hidden" + ] + } + } + }, + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "ad86ca2613973d89e49b3bea1b3f0c8cf0885e431aea0cd349998de717c5e776" +} diff --git a/.sqlx/query-e41f28efcfaf2efee773dc6e7261d2f63eb81886f7f73e989807d6e4d64a01ea.json b/.sqlx/query-b7a265d9e8e14728f9e6b469d52c7c236aa9113b4d95aee0b1318e5a9f1bb4f1.json similarity index 62% rename from .sqlx/query-e41f28efcfaf2efee773dc6e7261d2f63eb81886f7f73e989807d6e4d64a01ea.json rename to .sqlx/query-b7a265d9e8e14728f9e6b469d52c7c236aa9113b4d95aee0b1318e5a9f1bb4f1.json index 68a5123..3219e19 100644 --- a/.sqlx/query-e41f28efcfaf2efee773dc6e7261d2f63eb81886f7f73e989807d6e4d64a01ea.json +++ b/.sqlx/query-b7a265d9e8e14728f9e6b469d52c7c236aa9113b4d95aee0b1318e5a9f1bb4f1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT \n t.id, \n t.slug, \n t.name,\n t.color,\n t.created_at,\n tc.count\n FROM tag_cooccurrence tc\n JOIN tags t ON (tc.tag_a = t.id OR tc.tag_b = t.id)\n WHERE (tc.tag_a = $1 OR tc.tag_b = $1) AND t.id != $1\n ORDER BY tc.count DESC, t.name ASC\n LIMIT $2\n ", + "query": "\n SELECT \n t.id, \n t.slug, \n t.name,\n t.icon,\n t.color,\n t.created_at,\n tc.count\n FROM tag_cooccurrence tc\n JOIN tags t ON (tc.tag_a = t.id OR tc.tag_b = t.id)\n WHERE (tc.tag_a = $1 OR tc.tag_b = $1) AND t.id != $1\n ORDER BY tc.count DESC, t.name ASC\n LIMIT $2\n ", "describe": { "columns": [ { @@ -20,16 +20,21 @@ }, { "ordinal": 3, + "name": "icon", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "color", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 5, + "ordinal": 6, "name": "count", "type_info": "Int4" } @@ -45,9 +50,10 @@ false, false, true, + true, false, false ] }, - "hash": "e41f28efcfaf2efee773dc6e7261d2f63eb81886f7f73e989807d6e4d64a01ea" + "hash": "b7a265d9e8e14728f9e6b469d52c7c236aa9113b4d95aee0b1318e5a9f1bb4f1" } diff --git a/.sqlx/query-9dad9afb86f77510f5ec3eb7e1487945b7052370f0fa9d6d5709978d3fbe76cd.json b/.sqlx/query-d50291c2483aa95fa6a734d69dcd83fc4a68d4917721cbca149bc841b39a9481.json similarity index 68% rename from .sqlx/query-9dad9afb86f77510f5ec3eb7e1487945b7052370f0fa9d6d5709978d3fbe76cd.json rename to .sqlx/query-d50291c2483aa95fa6a734d69dcd83fc4a68d4917721cbca149bc841b39a9481.json index d6277a5..ea71c8f 100644 --- a/.sqlx/query-9dad9afb86f77510f5ec3eb7e1487945b7052370f0fa9d6d5709978d3fbe76cd.json +++ b/.sqlx/query-d50291c2483aa95fa6a734d69dcd83fc4a68d4917721cbca149bc841b39a9481.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, slug, name, color, created_at\n FROM tags\n ORDER BY name ASC\n ", + "query": "\n SELECT id, slug, name, icon, color, created_at\n FROM tags\n ORDER BY name ASC\n ", "describe": { "columns": [ { @@ -20,11 +20,16 @@ }, { "ordinal": 3, + "name": "icon", + "type_info": "Text" + }, + { + "ordinal": 4, "name": "color", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" } @@ -37,8 +42,9 @@ false, false, true, + true, false ] }, - "hash": "9dad9afb86f77510f5ec3eb7e1487945b7052370f0fa9d6d5709978d3fbe76cd" + "hash": "d50291c2483aa95fa6a734d69dcd83fc4a68d4917721cbca149bc841b39a9481" } diff --git a/.sqlx/query-e3c4842a1151f51f3871212c3e591103d04a7ff27b27d7d197fbd53593c06b74.json b/.sqlx/query-e3c4842a1151f51f3871212c3e591103d04a7ff27b27d7d197fbd53593c06b74.json new file mode 100644 index 0000000..3bc4a87 --- /dev/null +++ b/.sqlx/query-e3c4842a1151f51f3871212c3e591103d04a7ff27b27d7d197fbd53593c06b74.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n id, \n slug, \n name,\n short_description,\n description, \n status as \"status: ProjectStatus\", \n github_repo, \n demo_url, \n\n last_github_activity, \n created_at, \n updated_at\n FROM projects\n WHERE slug = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "short_description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status: ProjectStatus", + "type_info": { + "Custom": { + "name": "project_status", + "kind": { + "Enum": [ + "active", + "maintained", + "archived", + "hidden" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "github_repo", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "demo_url", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "last_github_activity", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "e3c4842a1151f51f3871212c3e591103d04a7ff27b27d7d197fbd53593c06b74" +} diff --git a/migrations/20260107002857_refactor_projects_schema.sql b/migrations/20260107002857_refactor_projects_schema.sql new file mode 100644 index 0000000..333d6b7 --- /dev/null +++ b/migrations/20260107002857_refactor_projects_schema.sql @@ -0,0 +1,9 @@ +-- Drop priority column and its index +DROP INDEX IF EXISTS idx_projects_priority; +ALTER TABLE projects DROP COLUMN priority; + +-- Rename title to name +ALTER TABLE projects RENAME COLUMN title TO name; + +-- Add short_description field +ALTER TABLE projects ADD COLUMN short_description TEXT NOT NULL DEFAULT ''; diff --git a/migrations/20260107004613_move_icons_to_tags.sql b/migrations/20260107004613_move_icons_to_tags.sql new file mode 100644 index 0000000..5bf5562 --- /dev/null +++ b/migrations/20260107004613_move_icons_to_tags.sql @@ -0,0 +1,5 @@ +-- Add icon field to tags +ALTER TABLE tags ADD COLUMN icon TEXT; + +-- Drop icon field from projects +ALTER TABLE projects DROP COLUMN icon; diff --git a/src/bin/seed.rs b/src/bin/seed.rs index 4af54e4..75d5766 100644 --- a/src/bin/seed.rs +++ b/src/bin/seed.rs @@ -9,7 +9,8 @@ async fn main() -> Result<(), Box> { println!("🌱 Seeding database..."); - // Clear existing data + // Clear existing data (tags will cascade delete project_tags and tag_cooccurrence) + sqlx::query("DELETE FROM tags").execute(&pool).await?; sqlx::query("DELETE FROM projects").execute(&pool).await?; // Seed projects with diverse data @@ -17,82 +18,75 @@ async fn main() -> Result<(), Box> { ( "xevion-dev", "xevion.dev", + "Personal portfolio and project showcase", "Personal portfolio site with fuzzy tag discovery and ISR caching", "active", Some("Xevion/xevion.dev"), None, - 10, - Some("lucide:globe"), ), ( "contest", "Contest", + "Competitive programming archive", "Archive and analysis platform for competitive programming problems", "active", Some("Xevion/contest"), Some("https://contest.xevion.dev"), - 9, - Some("lucide:trophy"), ), ( "reforge", "Reforge", + "Rocket League replay parser", "Rust library for parsing and manipulating Replay files from Rocket League", "maintained", Some("Xevion/reforge"), None, - 8, - Some("lucide:file-code"), ), ( "algorithms", "Algorithms", + "Algorithm implementations in Python", "Collection of algorithm implementations and data structures in Python", "archived", Some("Xevion/algorithms"), None, - 5, - Some("lucide:brain"), ), ( "wordplay", "WordPlay", + "Real-time multiplayer word game", "Interactive word game with real-time multiplayer using WebSockets", "maintained", Some("Xevion/wordplay"), Some("https://wordplay.example.com"), - 7, - Some("lucide:gamepad-2"), ), ( "dotfiles", "Dotfiles", + "Development environment configs", "Personal configuration files and development environment setup scripts", "active", Some("Xevion/dotfiles"), None, - 6, - Some("lucide:terminal"), ), ]; let project_count = projects.len(); - for (slug, title, desc, status, repo, demo, priority, icon) in projects { + for (slug, name, short_desc, desc, status, repo, demo) in projects { sqlx::query( r#" - INSERT INTO projects (slug, title, description, status, github_repo, demo_url, priority, icon) - VALUES ($1, $2, $3, $4::project_status, $5, $6, $7, $8) + INSERT INTO projects (slug, name, short_description, description, status, github_repo, demo_url) + VALUES ($1, $2, $3, $4, $5::project_status, $6, $7) "#, ) .bind(slug) - .bind(title) + .bind(name) + .bind(short_desc) .bind(desc) .bind(status) .bind(repo) .bind(demo) - .bind(priority) - .bind(icon) .execute(&pool) .await?; } @@ -101,31 +95,32 @@ async fn main() -> Result<(), Box> { // Seed tags let tags = vec![ - ("rust", "Rust"), - ("python", "Python"), - ("typescript", "TypeScript"), - ("javascript", "JavaScript"), - ("web", "Web"), - ("cli", "CLI"), - ("library", "Library"), - ("game", "Game"), - ("data-structures", "Data Structures"), - ("algorithms", "Algorithms"), - ("multiplayer", "Multiplayer"), - ("config", "Config"), + ("rust", "Rust", "simple-icons:rust"), + ("python", "Python", "simple-icons:python"), + ("typescript", "TypeScript", "simple-icons:typescript"), + ("javascript", "JavaScript", "simple-icons:javascript"), + ("web", "Web", "lucide:globe"), + ("cli", "CLI", "lucide:terminal"), + ("library", "Library", "lucide:package"), + ("game", "Game", "lucide:gamepad-2"), + ("data-structures", "Data Structures", "lucide:database"), + ("algorithms", "Algorithms", "lucide:cpu"), + ("multiplayer", "Multiplayer", "lucide:users"), + ("config", "Config", "lucide:settings"), ]; let mut tag_ids = std::collections::HashMap::new(); - for (slug, name) in tags { + for (slug, name, icon) in tags { let result = sqlx::query!( r#" - INSERT INTO tags (slug, name) - VALUES ($1, $2) + INSERT INTO tags (slug, name, icon) + VALUES ($1, $2, $3) RETURNING id "#, slug, - name + name, + icon ) .fetch_one(&pool) .await?; diff --git a/src/db.rs b/src/db.rs index 0524fbb..b480acf 100644 --- a/src/db.rs +++ b/src/db.rs @@ -19,13 +19,12 @@ pub enum ProjectStatus { pub struct DbProject { pub id: Uuid, pub slug: String, - pub title: String, + pub name: String, + pub short_description: String, pub description: String, pub status: ProjectStatus, pub github_repo: Option, pub demo_url: Option, - pub priority: i32, - pub icon: Option, pub last_github_activity: Option, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, @@ -37,6 +36,7 @@ pub struct DbTag { pub id: Uuid, pub slug: String, pub name: String, + pub icon: Option, pub color: Option, pub created_at: OffsetDateTime, } @@ -69,8 +69,6 @@ pub struct ApiProject { pub name: String, #[serde(rename = "shortDescription")] pub short_description: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub icon: Option, pub links: Vec, } @@ -80,6 +78,8 @@ pub struct ApiTag { pub slug: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub color: Option, } @@ -111,6 +111,7 @@ impl DbTag { id: self.id.to_string(), slug: self.slug.clone(), name: self.name.clone(), + icon: self.icon.clone(), color: self.color.clone(), } } @@ -138,9 +139,8 @@ impl DbProject { ApiProject { id: self.id.to_string(), slug: self.slug.clone(), - name: self.title.clone(), - short_description: self.description.clone(), - icon: self.icon.clone(), + name: self.name.clone(), + short_description: self.short_description.clone(), links, } } @@ -163,25 +163,38 @@ pub async fn get_public_projects(pool: &PgPool) -> Result, sqlx:: SELECT id, slug, - title, + name, + short_description, description, status as "status: ProjectStatus", github_repo, demo_url, - priority, - icon, last_github_activity, created_at, updated_at FROM projects WHERE status != 'hidden' - ORDER BY priority DESC, created_at DESC + ORDER BY updated_at DESC "# ) .fetch_all(pool) .await } +pub async fn get_public_projects_with_tags( + pool: &PgPool, +) -> Result)>, sqlx::Error> { + let projects = get_public_projects(pool).await?; + + let mut result = Vec::new(); + for project in projects { + let tags = get_tags_for_project(pool, project.id).await?; + result.push((project, tags)); + } + + Ok(result) +} + pub async fn health_check(pool: &PgPool) -> Result<(), sqlx::Error> { sqlx::query!("SELECT 1 as check") .fetch_one(pool) @@ -215,6 +228,7 @@ pub async fn create_tag( pool: &PgPool, name: &str, slug_override: Option<&str>, + icon: Option<&str>, color: Option<&str>, ) -> Result { let slug = slug_override @@ -224,12 +238,13 @@ pub async fn create_tag( sqlx::query_as!( DbTag, r#" - INSERT INTO tags (slug, name, color) - VALUES ($1, $2, $3) - RETURNING id, slug, name, color, created_at + INSERT INTO tags (slug, name, icon, color) + VALUES ($1, $2, $3, $4) + RETURNING id, slug, name, icon, color, created_at "#, slug, name, + icon, color ) .fetch_one(pool) @@ -240,7 +255,7 @@ pub async fn get_tag_by_id(pool: &PgPool, id: Uuid) -> Result, sql sqlx::query_as!( DbTag, r#" - SELECT id, slug, name, color, created_at + SELECT id, slug, name, icon, color, created_at FROM tags WHERE id = $1 "#, @@ -254,7 +269,7 @@ pub async fn get_tag_by_slug(pool: &PgPool, slug: &str) -> Result, sqlx::query_as!( DbTag, r#" - SELECT id, slug, name, color, created_at + SELECT id, slug, name, icon, color, created_at FROM tags WHERE slug = $1 "#, @@ -268,7 +283,7 @@ pub async fn get_all_tags(pool: &PgPool) -> Result, sqlx::Error> { sqlx::query_as!( DbTag, r#" - SELECT id, slug, name, color, created_at + SELECT id, slug, name, icon, color, created_at FROM tags ORDER BY name ASC "# @@ -284,12 +299,13 @@ pub async fn get_all_tags_with_counts(pool: &PgPool) -> Result t.id, t.slug, t.name, + t.icon, t.color, t.created_at, COUNT(pt.project_id)::int as "project_count!" FROM tags t LEFT JOIN project_tags pt ON t.id = pt.tag_id - GROUP BY t.id, t.slug, t.name, t.color, t.created_at + GROUP BY t.id, t.slug, t.name, t.icon, t.color, t.created_at ORDER BY t.name ASC "# ) @@ -303,6 +319,7 @@ pub async fn get_all_tags_with_counts(pool: &PgPool) -> Result id: row.id, slug: row.slug, name: row.name, + icon: row.icon, color: row.color, created_at: row.created_at, }; @@ -316,6 +333,7 @@ pub async fn update_tag( id: Uuid, name: &str, slug_override: Option<&str>, + icon: Option<&str>, color: Option<&str>, ) -> Result { let slug = slug_override @@ -326,13 +344,14 @@ pub async fn update_tag( DbTag, r#" UPDATE tags - SET slug = $2, name = $3, color = $4 + SET slug = $2, name = $3, icon = $4, color = $5 WHERE id = $1 - RETURNING id, slug, name, color, created_at + RETURNING id, slug, name, icon, color, created_at "#, id, slug, name, + icon, color ) .fetch_one(pool) @@ -415,7 +434,7 @@ pub async fn get_tags_for_project( sqlx::query_as!( DbTag, r#" - SELECT t.id, t.slug, t.name, t.color, t.created_at + SELECT t.id, t.slug, t.name, t.icon, t.color, t.created_at FROM tags t JOIN project_tags pt ON t.id = pt.tag_id WHERE pt.project_id = $1 @@ -437,20 +456,19 @@ pub async fn get_projects_for_tag( SELECT p.id, p.slug, - p.title, + p.name, + p.short_description, p.description, p.status as "status: ProjectStatus", p.github_repo, p.demo_url, - p.priority, - p.icon, p.last_github_activity, p.created_at, p.updated_at FROM projects p JOIN project_tags pt ON p.id = pt.project_id WHERE pt.tag_id = $1 - ORDER BY p.priority DESC, p.created_at DESC + ORDER BY p.updated_at DESC "#, tag_id ) @@ -498,6 +516,7 @@ pub async fn get_related_tags( t.id, t.slug, t.name, + t.icon, t.color, t.created_at, tc.count @@ -520,6 +539,7 @@ pub async fn get_related_tags( id: row.id, slug: row.slug, name: row.name, + icon: row.icon, color: row.color, created_at: row.created_at, }; @@ -527,3 +547,360 @@ pub async fn get_related_tags( }) .collect()) } + +// Project CRUD request/response types + +#[derive(Debug, Deserialize)] +pub struct CreateProjectRequest { + pub name: String, + pub slug: Option, + pub short_description: String, + pub description: String, + pub status: ProjectStatus, + pub github_repo: Option, + pub demo_url: Option, + pub tag_ids: Vec, // UUID strings +} + +#[derive(Debug, Deserialize)] +pub struct UpdateProjectRequest { + pub name: String, + pub slug: Option, + pub short_description: String, + pub description: String, + pub status: ProjectStatus, + pub github_repo: Option, + pub demo_url: Option, + pub tag_ids: Vec, // UUID strings +} + +// Response type for admin project list/detail (includes tags and metadata) +#[derive(Debug, Clone, Serialize)] +pub struct ApiAdminProject { + #[serde(flatten)] + pub project: ApiProject, + pub tags: Vec, + pub status: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub github_repo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub demo_url: Option, + #[serde(rename = "createdAt")] + pub created_at: String, // ISO 8601 + #[serde(rename = "updatedAt")] + pub updated_at: String, // ISO 8601 + #[serde(rename = "lastGithubActivity", skip_serializing_if = "Option::is_none")] + pub last_github_activity: Option, // ISO 8601 +} + +impl DbProject { + pub fn to_api_admin_project(&self, tags: Vec) -> ApiAdminProject { + ApiAdminProject { + project: self.to_api_project(), + tags: tags.into_iter().map(|t| t.to_api_tag()).collect(), + status: format!("{:?}", self.status).to_lowercase(), + description: self.description.clone(), + github_repo: self.github_repo.clone(), + demo_url: self.demo_url.clone(), + created_at: self + .created_at + .format(&time::format_description::well_known::Rfc3339) + .unwrap(), + updated_at: self + .updated_at + .format(&time::format_description::well_known::Rfc3339) + .unwrap(), + last_github_activity: self.last_github_activity.map(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .unwrap() + }), + } + } +} + +// Admin stats response +#[derive(Debug, Serialize)] +pub struct AdminStats { + #[serde(rename = "totalProjects")] + pub total_projects: i32, + #[serde(rename = "projectsByStatus")] + pub projects_by_status: serde_json::Value, + #[serde(rename = "totalTags")] + pub total_tags: i32, +} + +// Project CRUD queries + +/// Get all projects (admin view - includes hidden) +pub async fn get_all_projects_admin(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as!( + DbProject, + r#" + SELECT + id, + slug, + name, + short_description, + description, + status as "status: ProjectStatus", + github_repo, + demo_url, + last_github_activity, + created_at, + updated_at + FROM projects + ORDER BY updated_at DESC + "# + ) + .fetch_all(pool) + .await +} + +/// Get all projects with tags (admin view) +pub async fn get_all_projects_with_tags_admin( + pool: &PgPool, +) -> Result)>, sqlx::Error> { + let projects = get_all_projects_admin(pool).await?; + + let mut result = Vec::new(); + for project in projects { + let tags = get_tags_for_project(pool, project.id).await?; + result.push((project, tags)); + } + + Ok(result) +} + +/// Get single project by ID +pub async fn get_project_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + DbProject, + r#" + SELECT + id, + slug, + name, + short_description, + description, + status as "status: ProjectStatus", + github_repo, + demo_url, + + last_github_activity, + created_at, + updated_at + FROM projects + WHERE id = $1 + "#, + id + ) + .fetch_optional(pool) + .await +} + +/// Get single project by ID with tags +pub async fn get_project_by_id_with_tags( + pool: &PgPool, + id: Uuid, +) -> Result)>, sqlx::Error> { + let project = get_project_by_id(pool, id).await?; + + match project { + Some(p) => { + let tags = get_tags_for_project(pool, p.id).await?; + Ok(Some((p, tags))) + } + None => Ok(None), + } +} + +/// Get single project by slug +pub async fn get_project_by_slug( + pool: &PgPool, + slug: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + DbProject, + r#" + SELECT + id, + slug, + name, + short_description, + description, + status as "status: ProjectStatus", + github_repo, + demo_url, + + last_github_activity, + created_at, + updated_at + FROM projects + WHERE slug = $1 + "#, + slug + ) + .fetch_optional(pool) + .await +} + +/// Create project (without tags - tags handled separately) +pub async fn create_project( + pool: &PgPool, + name: &str, + slug_override: Option<&str>, + short_description: &str, + description: &str, + status: ProjectStatus, + github_repo: Option<&str>, + demo_url: Option<&str>, +) -> Result { + let slug = slug_override + .map(|s| slugify(s)) + .unwrap_or_else(|| slugify(name)); + + sqlx::query_as!( + DbProject, + r#" + INSERT INTO projects (slug, name, short_description, description, status, github_repo, demo_url) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, slug, name, short_description, description, status as "status: ProjectStatus", + github_repo, demo_url, last_github_activity, created_at, updated_at + "#, + slug, + name, + short_description, + description, + status as ProjectStatus, + github_repo, + demo_url + ) + .fetch_one(pool) + .await +} + +/// Update project (without tags - tags handled separately) +pub async fn update_project( + pool: &PgPool, + id: Uuid, + name: &str, + slug_override: Option<&str>, + short_description: &str, + description: &str, + status: ProjectStatus, + github_repo: Option<&str>, + demo_url: Option<&str>, +) -> Result { + let slug = slug_override + .map(|s| slugify(s)) + .unwrap_or_else(|| slugify(name)); + + sqlx::query_as!( + DbProject, + r#" + UPDATE projects + SET slug = $2, name = $3, short_description = $4, description = $5, + status = $6, github_repo = $7, demo_url = $8 + WHERE id = $1 + RETURNING id, slug, name, short_description, description, status as "status: ProjectStatus", + github_repo, demo_url, last_github_activity, created_at, updated_at + "#, + id, + slug, + name, + short_description, + description, + status as ProjectStatus, + github_repo, + demo_url + ) + .fetch_one(pool) + .await +} + +/// Delete project (CASCADE will handle tags) +pub async fn delete_project(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!("DELETE FROM projects WHERE id = $1", id) + .execute(pool) + .await?; + Ok(()) +} + +/// Set project tags (smart diff implementation) +pub async fn set_project_tags( + pool: &PgPool, + project_id: Uuid, + tag_ids: &[Uuid], +) -> Result<(), sqlx::Error> { + // Get current tags + let current_tags = get_tags_for_project(pool, project_id).await?; + let current_ids: Vec = current_tags.iter().map(|t| t.id).collect(); + + // Find tags to add (in new list but not in current) + let to_add: Vec = tag_ids + .iter() + .filter(|id| !current_ids.contains(id)) + .copied() + .collect(); + + // Find tags to remove (in current but not in new list) + let to_remove: Vec = current_ids + .iter() + .filter(|id| !tag_ids.contains(id)) + .copied() + .collect(); + + // Add new tags + for tag_id in to_add { + add_tag_to_project(pool, project_id, tag_id).await?; + } + + // Remove old tags + for tag_id in to_remove { + remove_tag_from_project(pool, project_id, tag_id).await?; + } + + Ok(()) +} + +/// Get admin stats +pub async fn get_admin_stats(pool: &PgPool) -> Result { + // Get project counts by status + let status_counts = sqlx::query!( + r#" + SELECT + status as "status!: ProjectStatus", + COUNT(*)::int as "count!" + FROM projects + GROUP BY status + "# + ) + .fetch_all(pool) + .await?; + + let mut projects_by_status = serde_json::json!({ + "active": 0, + "maintained": 0, + "archived": 0, + "hidden": 0, + }); + + let mut total_projects = 0; + for row in status_counts { + let status_str = format!("{:?}", row.status).to_lowercase(); + projects_by_status[status_str] = serde_json::json!(row.count); + total_projects += row.count; + } + + // Get total tags + let tag_count = sqlx::query!("SELECT COUNT(*)::int as \"count!\" FROM tags") + .fetch_one(pool) + .await?; + + Ok(AdminStats { + total_projects, + projects_by_status, + total_tags: tag_count.count, + }) +} diff --git a/src/main.rs b/src/main.rs index 13f131d..88073d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -350,8 +350,18 @@ fn api_routes() -> Router> { .route("/login", axum::routing::post(api_login_handler)) .route("/logout", axum::routing::post(api_logout_handler)) .route("/session", axum::routing::get(api_session_handler)) - // Projects - GET is public, other methods require auth - .route("/projects", axum::routing::get(projects_handler)) + // Projects - GET is public (shows all for admin, only non-hidden for public) + // POST/PUT/DELETE require authentication + .route( + "/projects", + axum::routing::get(projects_handler).post(create_project_handler), + ) + .route( + "/projects/{id}", + axum::routing::get(get_project_handler) + .put(update_project_handler) + .delete(delete_project_handler), + ) // Project tags - authentication checked in handlers .route( "/projects/{id}/tags", @@ -378,6 +388,8 @@ fn api_routes() -> Router> { "/tags/recalculate-cooccurrence", axum::routing::post(recalculate_cooccurrence_handler), ) + // Admin stats - requires authentication + .route("/stats", axum::routing::get(get_admin_stats_handler)) // Icon API - proxy to SvelteKit (authentication handled by SvelteKit) .route("/icons/{*path}", axum::routing::get(proxy_icons_handler)) .fallback(api_404_and_method_handler) @@ -510,23 +522,55 @@ async fn api_404_handler(uri: axum::http::Uri) -> impl IntoResponse { api_404_and_method_handler(req).await } -async fn projects_handler(State(state): State>) -> impl IntoResponse { - match db::get_public_projects(&state.pool).await { - Ok(projects) => { - let api_projects: Vec = - projects.into_iter().map(|p| p.to_api_project()).collect(); - Json(api_projects).into_response() +async fn projects_handler( + State(state): State>, + jar: axum_extra::extract::CookieJar, +) -> impl IntoResponse { + let is_admin = check_session(&state, &jar).is_some(); + + if is_admin { + // Admin view: return all projects with tags + match db::get_all_projects_with_tags_admin(&state.pool).await { + Ok(projects_with_tags) => { + let response: Vec = projects_with_tags + .into_iter() + .map(|(project, tags)| project.to_api_admin_project(tags)) + .collect(); + Json(response).into_response() + } + Err(err) => { + tracing::error!(error = %err, "Failed to fetch admin projects"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch projects" + })), + ) + .into_response() + } } - Err(err) => { - tracing::error!(error = %err, "Failed to fetch projects from database"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Internal server error", - "message": "Failed to fetch projects" - })), - ) - .into_response() + } else { + // Public view: return non-hidden projects with tags + match db::get_public_projects_with_tags(&state.pool).await { + Ok(projects_with_tags) => { + let response: Vec = projects_with_tags + .into_iter() + .map(|(project, tags)| project.to_api_admin_project(tags)) + .collect(); + Json(response).into_response() + } + Err(err) => { + tracing::error!(error = %err, "Failed to fetch public projects"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch projects" + })), + ) + .into_response() + } } } } @@ -582,6 +626,458 @@ async fn proxy_icons_handler( } } +// Project CRUD handlers + +async fn get_project_handler( + State(state): State>, + axum::extract::Path(id): axum::extract::Path, + jar: axum_extra::extract::CookieJar, +) -> impl IntoResponse { + let project_id = match uuid::Uuid::parse_str(&id) { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Invalid project ID", + "message": "Project ID must be a valid UUID" + })), + ) + .into_response(); + } + }; + + let is_admin = check_session(&state, &jar).is_some(); + + match db::get_project_by_id_with_tags(&state.pool, project_id).await { + Ok(Some((project, tags))) => { + // If project is hidden and user is not admin, return 404 + if project.status == db::ProjectStatus::Hidden && !is_admin { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "Not found", + "message": "Project not found" + })), + ) + .into_response(); + } + + // Return full project details + Json(project.to_api_admin_project(tags)).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "Not found", + "message": "Project not found" + })), + ) + .into_response(), + Err(err) => { + tracing::error!(error = %err, "Failed to fetch project"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch project" + })), + ) + .into_response() + } + } +} + +async fn create_project_handler( + State(state): State>, + jar: axum_extra::extract::CookieJar, + Json(payload): Json, +) -> impl IntoResponse { + // Check auth + if check_session(&state, &jar).is_none() { + return require_auth_response().into_response(); + } + + // Validate request + if payload.name.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation error", + "message": "Project name cannot be empty" + })), + ) + .into_response(); + } + + if payload.short_description.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation error", + "message": "Project short description cannot be empty" + })), + ) + .into_response(); + } + + // Parse tag UUIDs + let tag_ids: Result, _> = payload + .tag_ids + .iter() + .map(|id| uuid::Uuid::parse_str(id)) + .collect(); + + let tag_ids = match tag_ids { + Ok(ids) => ids, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation error", + "message": "Invalid tag UUID format" + })), + ) + .into_response(); + } + }; + + // Create project + let project = match db::create_project( + &state.pool, + &payload.name, + payload.slug.as_deref(), + &payload.short_description, + &payload.description, + payload.status, + payload.github_repo.as_deref(), + payload.demo_url.as_deref(), + ) + .await + { + Ok(p) => p, + Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "Conflict", + "message": "A project with this slug already exists" + })), + ) + .into_response(); + } + Err(err) => { + tracing::error!(error = %err, "Failed to create project"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to create project" + })), + ) + .into_response(); + } + }; + + // Set tags + if let Err(err) = db::set_project_tags(&state.pool, project.id, &tag_ids).await { + tracing::error!(error = %err, project_id = %project.id, "Failed to set project tags"); + } + + // Fetch project with tags to return + let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project.id).await { + Ok(Some(data)) => data, + Ok(None) => { + tracing::error!(project_id = %project.id, "Project not found after creation"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch created project" + })), + ) + .into_response(); + } + Err(err) => { + tracing::error!(error = %err, project_id = %project.id, "Failed to fetch created project"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch created project" + })), + ) + .into_response(); + } + }; + + tracing::info!(project_id = %project.id, project_name = %project.name, "Project created"); + + ( + StatusCode::CREATED, + Json(project.to_api_admin_project(tags)), + ) + .into_response() +} + +async fn update_project_handler( + State(state): State>, + axum::extract::Path(id): axum::extract::Path, + jar: axum_extra::extract::CookieJar, + Json(payload): Json, +) -> impl IntoResponse { + // Check auth + if check_session(&state, &jar).is_none() { + return require_auth_response().into_response(); + } + + // Parse project ID + let project_id = match uuid::Uuid::parse_str(&id) { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Invalid project ID", + "message": "Project ID must be a valid UUID" + })), + ) + .into_response(); + } + }; + + // Validate exists + if db::get_project_by_id(&state.pool, project_id) + .await + .ok() + .flatten() + .is_none() + { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "Not found", + "message": "Project not found" + })), + ) + .into_response(); + } + + // Validate request + if payload.name.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation error", + "message": "Project name cannot be empty" + })), + ) + .into_response(); + } + + if payload.short_description.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation error", + "message": "Project short description cannot be empty" + })), + ) + .into_response(); + } + + // Parse tag UUIDs + let tag_ids: Result, _> = payload + .tag_ids + .iter() + .map(|id| uuid::Uuid::parse_str(id)) + .collect(); + + let tag_ids = match tag_ids { + Ok(ids) => ids, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation error", + "message": "Invalid tag UUID format" + })), + ) + .into_response(); + } + }; + + // Update project + let project = match db::update_project( + &state.pool, + project_id, + &payload.name, + payload.slug.as_deref(), + &payload.short_description, + &payload.description, + payload.status, + payload.github_repo.as_deref(), + payload.demo_url.as_deref(), + ) + .await + { + Ok(p) => p, + Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "Conflict", + "message": "A project with this slug already exists" + })), + ) + .into_response(); + } + Err(err) => { + tracing::error!(error = %err, "Failed to update project"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to update project" + })), + ) + .into_response(); + } + }; + + // Update tags (smart diff) + if let Err(err) = db::set_project_tags(&state.pool, project.id, &tag_ids).await { + tracing::error!(error = %err, project_id = %project.id, "Failed to update project tags"); + } + + // Fetch updated project with tags + let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project.id).await { + Ok(Some(data)) => data, + Ok(None) => { + tracing::error!(project_id = %project.id, "Project not found after update"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch updated project" + })), + ) + .into_response(); + } + Err(err) => { + tracing::error!(error = %err, project_id = %project.id, "Failed to fetch updated project"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch updated project" + })), + ) + .into_response(); + } + }; + + tracing::info!(project_id = %project.id, project_name = %project.name, "Project updated"); + + Json(project.to_api_admin_project(tags)).into_response() +} + +async fn delete_project_handler( + State(state): State>, + axum::extract::Path(id): axum::extract::Path, + jar: axum_extra::extract::CookieJar, +) -> impl IntoResponse { + // Check auth + if check_session(&state, &jar).is_none() { + return require_auth_response().into_response(); + } + + // Parse project ID + let project_id = match uuid::Uuid::parse_str(&id) { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Invalid project ID", + "message": "Project ID must be a valid UUID" + })), + ) + .into_response(); + } + }; + + // Fetch project before deletion to return it + let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project_id).await { + Ok(Some(data)) => data, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "Not found", + "message": "Project not found" + })), + ) + .into_response(); + } + Err(err) => { + tracing::error!(error = %err, "Failed to fetch project before deletion"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to delete project" + })), + ) + .into_response(); + } + }; + + // Delete project (CASCADE handles tags) + match db::delete_project(&state.pool, project_id).await { + Ok(()) => { + tracing::info!(project_id = %project_id, project_name = %project.name, "Project deleted"); + Json(project.to_api_admin_project(tags)).into_response() + } + Err(err) => { + tracing::error!(error = %err, "Failed to delete project"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to delete project" + })), + ) + .into_response() + } + } +} + +async fn get_admin_stats_handler( + State(state): State>, + jar: axum_extra::extract::CookieJar, +) -> impl IntoResponse { + // Check auth + if check_session(&state, &jar).is_none() { + return require_auth_response().into_response(); + } + + match db::get_admin_stats(&state.pool).await { + Ok(stats) => Json(stats).into_response(), + Err(err) => { + tracing::error!(error = %err, "Failed to fetch admin stats"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch statistics" + })), + ) + .into_response() + } + } +} + // Tag API handlers async fn list_tags_handler(State(state): State>) -> impl IntoResponse { @@ -659,6 +1155,7 @@ async fn create_tag_handler( &state.pool, &payload.name, payload.slug.as_deref(), + None, // icon - not yet supported in admin UI payload.color.as_deref(), ) .await @@ -804,6 +1301,7 @@ async fn update_tag_handler( tag.id, &payload.name, payload.slug.as_deref(), + None, // icon - not yet supported in admin UI payload.color.as_deref(), ) .await diff --git a/web/src/lib/admin-types.ts b/web/src/lib/admin-types.ts index f0c3d5b..64543bc 100644 --- a/web/src/lib/admin-types.ts +++ b/web/src/lib/admin-types.ts @@ -6,8 +6,9 @@ export interface AdminTag { id: string; slug: string; name: string; + icon?: string; color?: string; - createdAt: string; + createdAt?: string; } export interface AdminTagWithCount extends AdminTag { @@ -17,28 +18,27 @@ export interface AdminTagWithCount extends AdminTag { export interface AdminProject { id: string; slug: string; - title: string; + name: string; + shortDescription: string; description: string; status: ProjectStatus; - githubRepo: string | null; - demoUrl: string | null; - priority: number; - icon: string | null; - lastGithubActivity: string | null; + links: Array<{ url: string; title?: string }>; + tags: AdminTag[]; + githubRepo?: string | null; + demoUrl?: string | null; createdAt: string; updatedAt: string; - tags: AdminTag[]; + lastGithubActivity?: string | null; } export interface CreateProjectData { - title: string; + name: string; slug?: string; + shortDescription: string; description: string; status: ProjectStatus; githubRepo?: string; demoUrl?: string; - priority: number; - icon?: string; tagIds: string[]; } @@ -71,8 +71,6 @@ export interface AdminStats { totalProjects: number; projectsByStatus: Record; totalTags: number; - eventsToday: number; - errorsToday: number; } export interface AuthSession { diff --git a/web/src/lib/api.server.ts b/web/src/lib/api.server.ts index 18823f8..199d55c 100644 --- a/web/src/lib/api.server.ts +++ b/web/src/lib/api.server.ts @@ -10,7 +10,7 @@ const baseUrl = isUnixSocket ? "http://localhost" : upstreamUrl; export async function apiFetch( path: string, - init?: RequestInit, + init?: RequestInit & { fetch?: typeof fetch }, ): Promise { if (!upstreamUrl) { logger.error("UPSTREAM_URL environment variable not set"); @@ -19,12 +19,18 @@ export async function apiFetch( const url = `${baseUrl}${path}`; const method = init?.method ?? "GET"; + + // Unix sockets require Bun's native fetch (SvelteKit's fetch doesn't support it) + const fetchFn = isUnixSocket ? fetch : (init?.fetch ?? fetch); const fetchOptions: RequestInit & { unix?: string } = { ...init, signal: init?.signal ?? AbortSignal.timeout(30_000), }; + // Remove custom fetch property from options + delete (fetchOptions as any).fetch; + if (isUnixSocket) { fetchOptions.unix = upstreamUrl; } @@ -38,7 +44,7 @@ export async function apiFetch( }); try { - const response = await fetch(url, fetchOptions); + const response = await fetchFn(url, fetchOptions); if (!response.ok) { logger.error("API request failed", { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6443095..d3b95d1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -12,835 +12,173 @@ import type { } from "./admin-types"; // ============================================================================ -// ADMIN API FUNCTIONS (Mocked for now, will be replaced with real API calls) +// CLIENT-SIDE API FUNCTIONS // ============================================================================ -// Mock data storage (in-memory for now) -const MOCK_TAGS: AdminTag[] = [ - { - id: "tag-1", - slug: "rust", - name: "Rust", - createdAt: "2024-01-15T10:00:00Z", - }, - { - id: "tag-2", - slug: "typescript", - name: "TypeScript", - createdAt: "2024-01-16T10:00:00Z", - }, - { id: "tag-3", slug: "web", name: "Web", createdAt: "2024-01-17T10:00:00Z" }, - { id: "tag-4", slug: "cli", name: "CLI", createdAt: "2024-01-18T10:00:00Z" }, - { id: "tag-5", slug: "api", name: "API", createdAt: "2024-01-19T10:00:00Z" }, - { - id: "tag-6", - slug: "database", - name: "Database", - createdAt: "2024-01-20T10:00:00Z", - }, - { - id: "tag-7", - slug: "svelte", - name: "Svelte", - createdAt: "2024-01-21T10:00:00Z", - }, - { - id: "tag-8", - slug: "python", - name: "Python", - createdAt: "2024-01-22T10:00:00Z", - }, - { - id: "tag-9", - slug: "machine-learning", - name: "Machine Learning", - createdAt: "2024-01-23T10:00:00Z", - }, - { - id: "tag-10", - slug: "docker", - name: "Docker", - createdAt: "2024-01-24T10:00:00Z", - }, - { - id: "tag-11", - slug: "kubernetes", - name: "Kubernetes", - createdAt: "2024-01-25T10:00:00Z", - }, - { - id: "tag-12", - slug: "react", - name: "React", - createdAt: "2024-01-26T10:00:00Z", - }, - { - id: "tag-13", - slug: "nextjs", - name: "Next.js", - createdAt: "2024-01-27T10:00:00Z", - }, - { - id: "tag-14", - slug: "tailwind", - name: "Tailwind CSS", - createdAt: "2024-01-28T10:00:00Z", - }, - { - id: "tag-15", - slug: "graphql", - name: "GraphQL", - createdAt: "2024-01-29T10:00:00Z", - }, - { - id: "tag-16", - slug: "postgres", - name: "PostgreSQL", - createdAt: "2024-01-30T10:00:00Z", - }, - { - id: "tag-17", - slug: "redis", - name: "Redis", - createdAt: "2024-01-31T10:00:00Z", - }, - { id: "tag-18", slug: "aws", name: "AWS", createdAt: "2024-02-01T10:00:00Z" }, - { - id: "tag-19", - slug: "devops", - name: "DevOps", - createdAt: "2024-02-02T10:00:00Z", - }, - { - id: "tag-20", - slug: "security", - name: "Security", - createdAt: "2024-02-03T10:00:00Z", - }, -]; - -const MOCK_PROJECTS: AdminProject[] = [ - { - id: "proj-1", - slug: "portfolio-site", - title: "Portfolio Site", - description: "Personal portfolio with project showcase and blog", - status: "active", - githubRepo: "xevion/xevion.dev", - demoUrl: "https://xevion.dev", - priority: 100, - icon: "fa-globe", - lastGithubActivity: "2024-12-20T15:30:00Z", - createdAt: "2024-01-10T08:00:00Z", - updatedAt: "2024-12-20T15:30:00Z", - tags: [MOCK_TAGS[0], MOCK_TAGS[1], MOCK_TAGS[6], MOCK_TAGS[13]], - }, - { - id: "proj-2", - slug: "task-tracker", - title: "Task Tracker CLI", - description: "Command-line task management tool with SQLite backend", - status: "maintained", - githubRepo: "xevion/task-tracker", - demoUrl: null, - priority: 90, - icon: "fa-check-square", - lastGithubActivity: "2024-11-15T10:20:00Z", - createdAt: "2024-02-05T12:00:00Z", - updatedAt: "2024-11-15T10:20:00Z", - tags: [MOCK_TAGS[0], MOCK_TAGS[3], MOCK_TAGS[5]], - }, - { - id: "proj-3", - slug: "api-gateway", - title: "API Gateway Service", - description: "High-performance API gateway with rate limiting and caching", - status: "active", - githubRepo: "xevion/api-gateway", - demoUrl: null, - priority: 85, - icon: "fa-server", - lastGithubActivity: "2025-01-05T14:45:00Z", - createdAt: "2024-03-12T09:30:00Z", - updatedAt: "2025-01-05T14:45:00Z", - tags: [MOCK_TAGS[0], MOCK_TAGS[4], MOCK_TAGS[16], MOCK_TAGS[19]], - }, - { - id: "proj-4", - slug: "data-pipeline", - title: "Data Pipeline Framework", - description: "ETL framework for processing large datasets", - status: "archived", - githubRepo: "xevion/data-pipeline", - demoUrl: null, - priority: 50, - icon: "fa-database", - lastGithubActivity: "2024-06-10T08:15:00Z", - createdAt: "2024-01-20T11:00:00Z", - updatedAt: "2024-06-10T08:15:00Z", - tags: [MOCK_TAGS[7], MOCK_TAGS[5], MOCK_TAGS[15]], - }, - { - id: "proj-5", - slug: "ml-classifier", - title: "ML Image Classifier", - description: "Deep learning model for image classification", - status: "active", - githubRepo: "xevion/ml-classifier", - demoUrl: "https://ml-demo.xevion.dev", - priority: 80, - icon: "fa-brain", - lastGithubActivity: "2024-12-28T16:00:00Z", - createdAt: "2024-04-01T13:00:00Z", - updatedAt: "2024-12-28T16:00:00Z", - tags: [MOCK_TAGS[7], MOCK_TAGS[8], MOCK_TAGS[9]], - }, - { - id: "proj-6", - slug: "container-orchestrator", - title: "Container Orchestrator", - description: "Lightweight container orchestration for small deployments", - status: "active", - githubRepo: "xevion/orchestrator", - demoUrl: null, - priority: 75, - icon: "fa-ship", - lastGithubActivity: "2025-01-02T09:30:00Z", - createdAt: "2024-05-10T10:00:00Z", - updatedAt: "2025-01-02T09:30:00Z", - tags: [MOCK_TAGS[0], MOCK_TAGS[9], MOCK_TAGS[10], MOCK_TAGS[18]], - }, - { - id: "proj-7", - slug: "dashboard-components", - title: "Dashboard Component Library", - description: "Reusable React components for building admin dashboards", - status: "maintained", - githubRepo: "xevion/dashboard-ui", - demoUrl: "https://dashboard-demo.xevion.dev", - priority: 70, - icon: "fa-th-large", - lastGithubActivity: "2024-10-20T12:00:00Z", - createdAt: "2024-02-15T14:30:00Z", - updatedAt: "2024-10-20T12:00:00Z", - tags: [MOCK_TAGS[1], MOCK_TAGS[11], MOCK_TAGS[13]], - }, - { - id: "proj-8", - slug: "graphql-server", - title: "GraphQL Server Boilerplate", - description: "Production-ready GraphQL server with auth and subscriptions", - status: "active", - githubRepo: "xevion/graphql-server", - demoUrl: null, - priority: 65, - icon: "fa-project-diagram", - lastGithubActivity: "2024-12-15T11:30:00Z", - createdAt: "2024-03-20T08:00:00Z", - updatedAt: "2024-12-15T11:30:00Z", - tags: [MOCK_TAGS[1], MOCK_TAGS[4], MOCK_TAGS[14], MOCK_TAGS[15]], - }, - { - id: "proj-9", - slug: "security-scanner", - title: "Security Scanner", - description: - "Automated security vulnerability scanner for web applications", - status: "active", - githubRepo: "xevion/sec-scanner", - demoUrl: null, - priority: 60, - icon: "fa-shield-alt", - lastGithubActivity: "2024-12-30T10:00:00Z", - createdAt: "2024-06-01T09:00:00Z", - updatedAt: "2024-12-30T10:00:00Z", - tags: [MOCK_TAGS[7], MOCK_TAGS[2], MOCK_TAGS[19]], - }, - { - id: "proj-10", - slug: "cache-optimizer", - title: "Cache Optimization Library", - description: "Smart caching layer with automatic invalidation", - status: "maintained", - githubRepo: "xevion/cache-lib", - demoUrl: null, - priority: 55, - icon: "fa-bolt", - lastGithubActivity: "2024-09-10T13:20:00Z", - createdAt: "2024-04-15T10:30:00Z", - updatedAt: "2024-09-10T13:20:00Z", - tags: [MOCK_TAGS[0], MOCK_TAGS[16], MOCK_TAGS[4]], - }, - { - id: "proj-11", - slug: "deployment-tools", - title: "Deployment Automation Tools", - description: - "CLI tools for automated deployments to multiple cloud providers", - status: "active", - githubRepo: "xevion/deploy-tools", - demoUrl: null, - priority: 50, - icon: "fa-rocket", - lastGithubActivity: "2025-01-01T08:00:00Z", - createdAt: "2024-07-10T11:00:00Z", - updatedAt: "2025-01-01T08:00:00Z", - tags: [MOCK_TAGS[0], MOCK_TAGS[3], MOCK_TAGS[18], MOCK_TAGS[18]], - }, - { - id: "proj-12", - slug: "log-aggregator", - title: "Log Aggregation Service", - description: "Centralized logging with search and analytics", - status: "active", - githubRepo: "xevion/log-aggregator", - demoUrl: null, - priority: 45, - icon: "fa-file-alt", - lastGithubActivity: "2024-12-25T15:00:00Z", - createdAt: "2024-08-05T12:00:00Z", - updatedAt: "2024-12-25T15:00:00Z", - tags: [MOCK_TAGS[0], MOCK_TAGS[5], MOCK_TAGS[15]], - }, - { - id: "proj-13", - slug: "ui-playground", - title: "UI Component Playground", - description: "Interactive playground for testing UI components", - status: "maintained", - githubRepo: "xevion/ui-playground", - demoUrl: "https://ui.xevion.dev", - priority: 40, - icon: "fa-palette", - lastGithubActivity: "2024-08-20T10:30:00Z", - createdAt: "2024-05-20T09:00:00Z", - updatedAt: "2024-08-20T10:30:00Z", - tags: [MOCK_TAGS[1], MOCK_TAGS[11], MOCK_TAGS[13]], - }, - { - id: "proj-14", - slug: "config-manager", - title: "Configuration Manager", - description: "Type-safe configuration management for microservices", - status: "archived", - githubRepo: "xevion/config-manager", - demoUrl: null, - priority: 30, - icon: "fa-cog", - lastGithubActivity: "2024-05-15T14:00:00Z", - createdAt: "2024-02-28T11:30:00Z", - updatedAt: "2024-05-15T14:00:00Z", - tags: [MOCK_TAGS[0], MOCK_TAGS[1]], - }, - { - id: "proj-15", - slug: "websocket-proxy", - title: "WebSocket Proxy", - description: "Scalable WebSocket proxy with load balancing", - status: "active", - githubRepo: "xevion/ws-proxy", - demoUrl: null, - priority: 35, - icon: "fa-exchange-alt", - lastGithubActivity: "2024-11-30T16:30:00Z", - createdAt: "2024-06-15T13:00:00Z", - updatedAt: "2024-11-30T16:30:00Z", - tags: [MOCK_TAGS[0], MOCK_TAGS[2], MOCK_TAGS[4]], - }, -]; - -const MOCK_EVENTS: AdminEvent[] = [ - { - id: "evt-1", - timestamp: "2025-01-06T10:30:00Z", - level: "info", - target: "project.created", - message: "Created new project: Portfolio Site", - metadata: { projectId: "proj-1", userId: "admin" }, - }, - { - id: "evt-2", - timestamp: "2025-01-06T09:15:00Z", - level: "info", - target: "github.sync", - message: "GitHub sync completed for 15 projects", - metadata: { projectCount: 15, duration: 2340 }, - }, - { - id: "evt-3", - timestamp: "2025-01-06T08:45:00Z", - level: "warning", - target: "github.sync", - message: "Rate limit approaching: 450/5000 requests remaining", - metadata: { remaining: 450, limit: 5000 }, - }, - { - id: "evt-4", - timestamp: "2025-01-06T08:00:00Z", - level: "error", - target: "github.sync", - message: "Failed to sync project: ml-classifier", - metadata: { projectId: "proj-5", error: "Repository not found" }, - }, - { - id: "evt-5", - timestamp: "2025-01-06T07:30:00Z", - level: "info", - target: "tag.created", - message: "Created new tag: Rust", - metadata: { tagId: "tag-1" }, - }, - { - id: "evt-6", - timestamp: "2025-01-05T23:00:00Z", - level: "info", - target: "project.updated", - message: "Updated project: API Gateway Service", - metadata: { projectId: "proj-3", changes: ["description", "tags"] }, - }, - { - id: "evt-7", - timestamp: "2025-01-05T22:15:00Z", - level: "info", - target: "tag.deleted", - message: "Deleted tag: Legacy", - metadata: { tagId: "tag-deleted", tagName: "Legacy" }, - }, - { - id: "evt-8", - timestamp: "2025-01-05T20:30:00Z", - level: "error", - target: "media.upload", - message: "Failed to upload media: file size exceeds limit", - metadata: { filename: "banner.png", size: 12582912, limit: 10485760 }, - }, - { - id: "evt-9", - timestamp: "2025-01-05T19:00:00Z", - level: "info", - target: "project.deleted", - message: "Deleted project: Old Website", - metadata: { projectId: "proj-old", projectName: "Old Website" }, - }, - { - id: "evt-10", - timestamp: "2025-01-05T18:30:00Z", - level: "warning", - target: "cache.invalidation", - message: "Cache invalidation took longer than expected", - metadata: { duration: 5420, threshold: 3000 }, - }, -]; - -// Generate additional events for scrolling test -for (let i = 11; i <= 100; i++) { - const levels: AdminEvent["level"][] = ["info", "warning", "error"]; - const targets = [ - "project.created", - "project.updated", - "project.deleted", - "tag.created", - "tag.updated", - "tag.deleted", - "github.sync", - "cache.invalidation", - "media.upload", - ]; - - const level = levels[Math.floor(Math.random() * levels.length)]; - const target = targets[Math.floor(Math.random() * targets.length)]; - const hoursAgo = i; - - const date = new Date(); - date.setHours(date.getHours() - hoursAgo); - - MOCK_EVENTS.push({ - id: `evt-${i}`, - timestamp: date.toISOString(), - level, - target, - message: `Mock event ${i}: ${target}`, - metadata: { eventNumber: i }, +// Client-side fetch wrapper for browser requests +async function clientApiFetch( + path: string, + init?: RequestInit, +): Promise { + const response = await fetch(path, { + ...init, + credentials: "same-origin", // Include cookies for auth }); + + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`); + } + + return response.json(); } -let MOCK_SETTINGS: SiteSettings = { - identity: { - displayName: "Ryan Walters", - occupation: "Full-Stack Software Engineer", - bio: "A fanatical software engineer with expertise and passion for sound, scalable and high-performance applications. I'm always working on something new.\nSometimes innovative — sometimes crazy.", - siteTitle: "Xevion.dev", - }, - socialLinks: [ - { - id: "social-1", - platform: "github", - label: "GitHub", - value: "https://github.com/Xevion", - visible: true, - }, - { - id: "social-2", - platform: "linkedin", - label: "LinkedIn", - value: "https://linkedin.com/in/ryancwalters", - visible: true, - }, - { - id: "social-3", - platform: "discord", - label: "Discord", - value: "xevion", - visible: true, - }, - { - id: "social-4", - platform: "email", - label: "Email", - value: "your.email@example.com", - visible: false, - }, - { - id: "social-5", - platform: "pgp", - label: "PGP Key", - value: "", - visible: false, - }, - ], - adminPreferences: { - sessionTimeoutMinutes: 60, - eventsRetentionDays: 30, - dashboardDefaultTab: "overview", - }, -}; - -function generateId(): string { - return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; -} - -function slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^\w\s-]/g, "") - .replace(/[\s_-]+/g, "-") - .replace(/^-+|-+$/g, ""); -} +// ============================================================================ +// ADMIN API FUNCTIONS +// ============================================================================ // Admin Projects API export async function getAdminProjects(): Promise { - // TODO: Replace with apiFetch('/admin/api/projects') when backend ready - await new Promise((resolve) => setTimeout(resolve, 100)); // Simulate network delay - return [...MOCK_PROJECTS].sort((a, b) => b.priority - a.priority); + return clientApiFetch("/api/projects"); } export async function getAdminProject( id: string, ): Promise { - // TODO: Replace with apiFetch(`/admin/api/projects/${id}`) when backend ready - await new Promise((resolve) => setTimeout(resolve, 50)); - return MOCK_PROJECTS.find((p) => p.id === id) || null; + try { + return await clientApiFetch(`/api/projects/${id}`); + } catch (error) { + // 404 errors should return null + if (error instanceof Error && error.message.includes("404")) { + return null; + } + throw error; + } } export async function createAdminProject( data: CreateProjectData, ): Promise { - // TODO: Replace with apiFetch('/admin/api/projects', { method: 'POST', body: JSON.stringify(data) }) - await new Promise((resolve) => setTimeout(resolve, 200)); - - const now = new Date().toISOString(); - const slug = data.slug || slugify(data.title); - const tags = MOCK_TAGS.filter((t) => data.tagIds.includes(t.id)); - - const newProject: AdminProject = { - id: generateId(), - slug, - title: data.title, - description: data.description, - status: data.status, - githubRepo: data.githubRepo || null, - demoUrl: data.demoUrl || null, - priority: data.priority, - icon: data.icon || null, - lastGithubActivity: null, - createdAt: now, - updatedAt: now, - tags, - }; - - MOCK_PROJECTS.push(newProject); - - // Add event - MOCK_EVENTS.unshift({ - id: generateId(), - timestamp: now, - level: "info", - target: "project.created", - message: `Created new project: ${newProject.title}`, - metadata: { projectId: newProject.id }, + return clientApiFetch("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), }); - - return newProject; } export async function updateAdminProject( data: UpdateProjectData, ): Promise { - // TODO: Replace with apiFetch(`/admin/api/projects/${data.id}`, { method: 'PUT', body: JSON.stringify(data) }) - await new Promise((resolve) => setTimeout(resolve, 200)); - - const index = MOCK_PROJECTS.findIndex((p) => p.id === data.id); - if (index === -1) throw new Error("Project not found"); - - const now = new Date().toISOString(); - const slug = data.slug || slugify(data.title); - const tags = MOCK_TAGS.filter((t) => data.tagIds.includes(t.id)); - - const updatedProject: AdminProject = { - ...MOCK_PROJECTS[index], - slug, - title: data.title, - description: data.description, - status: data.status, - githubRepo: data.githubRepo || null, - demoUrl: data.demoUrl || null, - priority: data.priority, - icon: data.icon || null, - updatedAt: now, - tags, - }; - - MOCK_PROJECTS[index] = updatedProject; - - // Add event - MOCK_EVENTS.unshift({ - id: generateId(), - timestamp: now, - level: "info", - target: "project.updated", - message: `Updated project: ${updatedProject.title}`, - metadata: { projectId: updatedProject.id }, + return clientApiFetch(`/api/projects/${data.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), }); - - return updatedProject; } -export async function deleteAdminProject(id: string): Promise { - // TODO: Replace with apiFetch(`/admin/api/projects/${id}`, { method: 'DELETE' }) - await new Promise((resolve) => setTimeout(resolve, 150)); - - const index = MOCK_PROJECTS.findIndex((p) => p.id === id); - if (index === -1) throw new Error("Project not found"); - - const project = MOCK_PROJECTS[index]; - MOCK_PROJECTS.splice(index, 1); - - // Add event - MOCK_EVENTS.unshift({ - id: generateId(), - timestamp: new Date().toISOString(), - level: "info", - target: "project.deleted", - message: `Deleted project: ${project.title}`, - metadata: { projectId: id, projectName: project.title }, +export async function deleteAdminProject(id: string): Promise { + return clientApiFetch(`/api/projects/${id}`, { + method: "DELETE", }); } // Admin Tags API export async function getAdminTags(): Promise { - // TODO: Replace with apiFetch('/admin/api/tags') when backend ready - await new Promise((resolve) => setTimeout(resolve, 80)); + const tags = await clientApiFetch< + Array + >("/api/tags"); - return MOCK_TAGS.map((tag) => { - const projectCount = MOCK_PROJECTS.filter((p) => - p.tags.some((t) => t.id === tag.id), - ).length; - return { ...tag, projectCount }; - }).sort((a, b) => a.name.localeCompare(b.name)); + // Transform snake_case to camelCase + return tags.map((item) => ({ + ...item, + projectCount: item.project_count, + })); } export async function createAdminTag(data: CreateTagData): Promise { - // TODO: Replace with apiFetch('/admin/api/tags', { method: 'POST', body: JSON.stringify(data) }) - await new Promise((resolve) => setTimeout(resolve, 150)); - - const now = new Date().toISOString(); - const slug = data.slug || slugify(data.name); - - const newTag: AdminTag = { - id: generateId(), - slug, - name: data.name, - createdAt: now, - }; - - MOCK_TAGS.push(newTag); - - // Add event - MOCK_EVENTS.unshift({ - id: generateId(), - timestamp: now, - level: "info", - target: "tag.created", - message: `Created new tag: ${newTag.name}`, - metadata: { tagId: newTag.id }, + return clientApiFetch("/api/tags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), }); - - return newTag; } export async function updateAdminTag(data: UpdateTagData): Promise { - // TODO: Replace with apiFetch(`/admin/api/tags/${data.id}`, { method: 'PUT', body: JSON.stringify(data) }) - await new Promise((resolve) => setTimeout(resolve, 150)); - - const index = MOCK_TAGS.findIndex((t) => t.id === data.id); - if (index === -1) throw new Error("Tag not found"); - - const slug = data.slug || slugify(data.name); - - const updatedTag: AdminTag = { - ...MOCK_TAGS[index], - slug, - name: data.name, - }; - - MOCK_TAGS[index] = updatedTag; - - // Update tag in all projects - MOCK_PROJECTS.forEach((project) => { - const tagIndex = project.tags.findIndex((t) => t.id === data.id); - if (tagIndex !== -1) { - project.tags[tagIndex] = updatedTag; - } + // Use the tag ID to construct the URL - need to get slug first + // For now, use ID directly (may need adjustment if backend expects slug) + return clientApiFetch(`/api/tags/${data.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), }); - - // Add event - MOCK_EVENTS.unshift({ - id: generateId(), - timestamp: new Date().toISOString(), - level: "info", - target: "tag.updated", - message: `Updated tag: ${updatedTag.name}`, - metadata: { tagId: updatedTag.id }, - }); - - return updatedTag; } export async function deleteAdminTag(id: string): Promise { - // TODO: Replace with apiFetch(`/admin/api/tags/${id}`, { method: 'DELETE' }) - await new Promise((resolve) => setTimeout(resolve, 120)); - - const index = MOCK_TAGS.findIndex((t) => t.id === id); - if (index === -1) throw new Error("Tag not found"); - - const tag = MOCK_TAGS[index]; - MOCK_TAGS.splice(index, 1); - - // Remove tag from all projects - MOCK_PROJECTS.forEach((project) => { - project.tags = project.tags.filter((t) => t.id !== id); - }); - - // Add event - MOCK_EVENTS.unshift({ - id: generateId(), - timestamp: new Date().toISOString(), - level: "info", - target: "tag.deleted", - message: `Deleted tag: ${tag.name}`, - metadata: { tagId: id, tagName: tag.name }, + // Delete by ID - may need to fetch slug first if backend expects it + await clientApiFetch(`/api/tags/${id}`, { + method: "DELETE", }); } -// Admin Events API +// Admin Events API (currently mocked - no backend implementation yet) export async function getAdminEvents(filters?: { level?: string; target?: string; limit?: number; }): Promise { - // TODO: Replace with apiFetch('/admin/api/events?...') when backend ready - await new Promise((resolve) => setTimeout(resolve, 100)); - - let events = [...MOCK_EVENTS]; - - if (filters?.level) { - events = events.filter((e) => e.level === filters.level); - } - - if (filters?.target) { - events = events.filter((e) => e.target.includes(filters.target!)); - } - - if (filters?.limit) { - events = events.slice(0, filters.limit); - } - - return events; + // TODO: Implement when events table is added to backend + return []; } // Admin Stats API export async function getAdminStats(): Promise { - // TODO: Replace with apiFetch('/admin/api/stats') when backend ready - await new Promise((resolve) => setTimeout(resolve, 80)); - - const projectsByStatus: Record = { - active: 0, - maintained: 0, - archived: 0, - hidden: 0, - }; - - MOCK_PROJECTS.forEach((p) => { - projectsByStatus[p.status]++; - }); - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const eventsToday = MOCK_EVENTS.filter( - (e) => new Date(e.timestamp) >= today, - ).length; - - const errorsToday = MOCK_EVENTS.filter( - (e) => e.level === "error" && new Date(e.timestamp) >= today, - ).length; - - return { - totalProjects: MOCK_PROJECTS.length, - projectsByStatus: projectsByStatus as Record< - "active" | "maintained" | "archived" | "hidden", - number - >, - totalTags: MOCK_TAGS.length, - eventsToday, - errorsToday, - }; + return clientApiFetch("/api/stats"); } -// Settings API +// Settings API (currently mocked - no backend implementation yet) export async function getSettings(): Promise { - // TODO: Replace with apiFetch('/admin/api/settings') when backend ready - await new Promise((resolve) => setTimeout(resolve, 100)); - return structuredClone(MOCK_SETTINGS); + // TODO: Implement when settings system is added + // For now, return default settings + return { + identity: { + displayName: "Ryan Walters", + occupation: "Full-Stack Software Engineer", + bio: "A fanatical software engineer with expertise and passion for sound, scalable and high-performance applications. I'm always working on something new.\nSometimes innovative — sometimes crazy.", + siteTitle: "Xevion.dev", + }, + socialLinks: [ + { + id: "social-1", + platform: "github", + label: "GitHub", + value: "https://github.com/Xevion", + visible: true, + }, + { + id: "social-2", + platform: "linkedin", + label: "LinkedIn", + value: "https://linkedin.com/in/ryancwalters", + visible: true, + }, + { + id: "social-3", + platform: "discord", + label: "Discord", + value: "xevion", + visible: true, + }, + ], + adminPreferences: { + sessionTimeoutMinutes: 60, + eventsRetentionDays: 30, + dashboardDefaultTab: "overview", + }, + }; } export async function updateSettings( settings: SiteSettings, ): Promise { - // TODO: Replace with apiFetch('/admin/api/settings', { method: 'PUT', body: JSON.stringify(settings) }) - await new Promise((resolve) => setTimeout(resolve, 200)); - - MOCK_SETTINGS = structuredClone(settings); - - // Add event - MOCK_EVENTS.unshift({ - id: generateId(), - timestamp: new Date().toISOString(), - level: "info", - target: "settings.updated", - message: "Site settings updated", - metadata: {}, - }); - - return structuredClone(MOCK_SETTINGS); + // TODO: Implement when settings system is added + return settings; } diff --git a/web/src/lib/components/ProjectCard.svelte b/web/src/lib/components/ProjectCard.svelte index f9c4bda..9d5c639 100644 --- a/web/src/lib/components/ProjectCard.svelte +++ b/web/src/lib/components/ProjectCard.svelte @@ -1,14 +1,20 @@ +{#if projectUrl}

- {project.description} + {project.shortDescription}

@@ -70,3 +77,44 @@ {/each}
+{:else} +
+
+
+

+ {project.name} +

+ + {formatDate(project.updatedAt)} + +
+

+ {project.shortDescription} +

+
+ +
+ {#each project.tags as tag (tag.name)} + + {#if tag.iconSvg} + + + {@html tag.iconSvg} + + {/if} + {tag.name} + + {/each} +
+
+{/if} diff --git a/web/src/lib/components/admin/ProjectForm.svelte b/web/src/lib/components/admin/ProjectForm.svelte index 0ef3cac..36e7903 100644 --- a/web/src/lib/components/admin/ProjectForm.svelte +++ b/web/src/lib/components/admin/ProjectForm.svelte @@ -2,7 +2,6 @@ import Button from "./Button.svelte"; import Input from "./Input.svelte"; import TagPicker from "./TagPicker.svelte"; - import IconPicker from "./IconPicker.svelte"; import type { AdminProject, AdminTag, @@ -25,27 +24,25 @@ }: Props = $props(); // Form state - let title = $state(""); + let name = $state(""); let slug = $state(""); + let shortDescription = $state(""); let description = $state(""); let status = $state("active"); let githubRepo = $state(""); let demoUrl = $state(""); - let icon = $state(""); - let priority = $state(0); let selectedTagIds = $state([]); // Initialize form from project prop $effect(() => { if (project) { - title = project.title; + name = project.name; slug = project.slug; + shortDescription = project.shortDescription; description = project.description; status = project.status; githubRepo = project.githubRepo ?? ""; demoUrl = project.demoUrl ?? ""; - icon = project.icon ?? ""; - priority = project.priority; selectedTagIds = project.tags.map((t) => t.id); } }); @@ -59,9 +56,9 @@ { value: "hidden", label: "Hidden" }, ]; - // Auto-generate slug placeholder from title + // Auto-generate slug placeholder from name const slugPlaceholder = $derived( - title + name .toLowerCase() .replace(/[^\w\s-]/g, "") .replace(/[\s_-]+/g, "-") @@ -78,14 +75,13 @@ try { await onsubmit({ - title, + name, slug: slug || slugPlaceholder, + shortDescription, description, status, githubRepo: githubRepo || undefined, demoUrl: demoUrl || undefined, - icon: icon || undefined, - priority, tagIds: selectedTagIds, }); } catch (error) { @@ -101,9 +97,9 @@
+ + + - -
- - - -
+ +
@@ -168,13 +164,6 @@ />
- - -
-
diff --git a/web/src/routes/+page.server.ts b/web/src/routes/+page.server.ts index cfe0a31..aa62a46 100644 --- a/web/src/routes/+page.server.ts +++ b/web/src/routes/+page.server.ts @@ -1,21 +1,18 @@ import type { PageServerLoad } from "./$types"; -import { MOCK_PROJECTS } from "$lib/mock-data/projects"; +import { apiFetch } from "$lib/api.server"; import { renderIconSVG } from "$lib/server/icons"; +import type { AdminProject } from "$lib/admin-types"; -// import { apiFetch } from '$lib/api.server'; -// import type { ApiProjectWithTags } from '$lib/admin-types'; +export const load: PageServerLoad = async ({ fetch }) => { + const projects = await apiFetch("/api/projects", { fetch }); -export const load: PageServerLoad = async () => { - // TODO: Replace with real API data - // const projects = await apiFetch('/api/projects', { fetch }); - - // Pre-render icon SVGs for tags (server-side only) + // Pre-render tag icons and clock icons (server-side only) const projectsWithIcons = await Promise.all( - MOCK_PROJECTS.map(async (project) => { + projects.map(async (project) => { const tagsWithIcons = await Promise.all( project.tags.map(async (tag) => ({ ...tag, - iconSvg: (await renderIconSVG(tag.icon, { size: 12 })) || "", + iconSvg: tag.icon ? (await renderIconSVG(tag.icon, { size: 12 })) || "" : "", })), ); diff --git a/web/src/routes/admin/projects/+page.svelte b/web/src/routes/admin/projects/+page.svelte index 9d4d9fd..f5e073d 100644 --- a/web/src/routes/admin/projects/+page.svelte +++ b/web/src/routes/admin/projects/+page.svelte @@ -107,7 +107,7 @@ - Title + Name Status @@ -115,9 +115,6 @@ Tags - - Priority - Updated @@ -133,7 +130,7 @@
- {project.title} + {project.name}
{project.slug} @@ -156,9 +153,6 @@ {/if}
- - {project.priority} - {formatDate(project.updatedAt)} @@ -199,7 +193,7 @@ > {#if deleteTarget}
-

{deleteTarget.title}

+

{deleteTarget.name}

{deleteTarget.slug}

{/if}