diff --git a/api/internal/httpapi/admin.go b/api/internal/httpapi/admin.go index 8ed602b..630fe6b 100644 --- a/api/internal/httpapi/admin.go +++ b/api/internal/httpapi/admin.go @@ -19,8 +19,8 @@ func (s *Server) adminListCampaigns(c *gin.Context) { c.JSON(http.StatusOK, rows) } -func (s *Server) adminOrganizations(c *gin.Context) { - orgs, err := s.store.ListOrganizations(c.Request.Context()) +func (s *Server) adminOwners(c *gin.Context) { + orgs, err := s.store.ListOwners(c.Request.Context()) if err != nil { s.fail(c, err) return @@ -62,7 +62,7 @@ func (s *Server) adminSaveCampaign(c *gin.Context) { in.Slug = hu.Slugify(firstNonEmpty(in.Slug, in.Title)) if in.Title == "" || in.Slug == "" || in.GoalAmount <= 0 || - in.EndsAt == "" || in.OrganizationID == "" { + in.EndsAt == "" || in.OwnerID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "hianyzo"}) return } diff --git a/api/internal/httpapi/router.go b/api/internal/httpapi/router.go index bccf0f3..f724416 100644 --- a/api/internal/httpapi/router.go +++ b/api/internal/httpapi/router.go @@ -48,7 +48,7 @@ func (s *Server) Handler() http.Handler { { admin.GET("/campaigns", s.adminListCampaigns) admin.GET("/campaigns/:slug", s.adminGetCampaign) - admin.GET("/organizations", s.adminOrganizations) + admin.GET("/owners", s.adminOwners) admin.PUT("/campaigns", s.sameOrigin, s.adminSaveCampaign) admin.DELETE("/campaigns/:slug", s.sameOrigin, s.adminDeleteCampaign) } diff --git a/api/internal/store/admin.go b/api/internal/store/admin.go index 971986f..e7bf9c1 100644 --- a/api/internal/store/admin.go +++ b/api/internal/store/admin.go @@ -13,18 +13,23 @@ import ( // has to round-trip DRAFT and ELUTASITVA, which never reach a visitor. type AdminCampaignRow struct { - ID string `json:"id"` - Slug string `json:"slug"` - Title string `json:"title"` - Status string `json:"status"` - GoalAmount int `json:"goalAmount"` - Organization string `json:"organization"` - PledgeCount int `json:"pledgeCount"` + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Status string `json:"status"` + GoalAmount int `json:"goalAmount"` + Owner string `json:"owner"` + PledgeCount int `json:"pledgeCount"` } -type Organization struct { +// Owner is the person a campaign belongs to. Always a person — the table was +// called Organization until migration 0003. +type Owner struct { ID string `json:"id"` Name string `json:"name"` + // Carried so an owner editor can show and change the photo. Empty means the + // organisation has none and its initials are used instead. + AvatarURL string `json:"avatarUrl,omitempty"` } type AdminTier struct { @@ -43,18 +48,18 @@ type AdminTier struct { } type AdminCampaign struct { - ID string `json:"id"` - Slug string `json:"slug"` - Title string `json:"title"` - Category string `json:"category"` - Summary string `json:"summary"` - Art string `json:"art"` - GoalAmount int `json:"goalAmount"` - EndsAt string `json:"endsAt"` - Status string `json:"status"` - OrganizationID string `json:"organizationId"` - Story []StorySection `json:"story"` - Tiers []AdminTier `json:"tiers"` + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Category string `json:"category"` + Summary string `json:"summary"` + Art string `json:"art"` + GoalAmount int `json:"goalAmount"` + EndsAt string `json:"endsAt"` + Status string `json:"status"` + OwnerID string `json:"ownerId"` + Story []StorySection `json:"story"` + Tiers []AdminTier `json:"tiers"` } // ErrSlugTaken and ErrHasPledges are the two failures the editor has to @@ -69,7 +74,7 @@ func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, err SELECT c."id", c."slug", c."title", c."status"::text, c."goalAmount", o."name", (SELECT COUNT(*) FROM "Pledge" p WHERE p."campaignId" = c."id")::int FROM "Campaign" c - JOIN "Organization" o ON o."id" = c."organizationId" + JOIN "Owner" o ON o."id" = c."ownerId" ORDER BY c."createdAt" DESC`) if err != nil { return nil, err @@ -80,7 +85,7 @@ func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, err for rows.Next() { var r AdminCampaignRow if err := rows.Scan(&r.ID, &r.Slug, &r.Title, &r.Status, &r.GoalAmount, - &r.Organization, &r.PledgeCount); err != nil { + &r.Owner, &r.PledgeCount); err != nil { return nil, err } out = append(out, r) @@ -88,18 +93,18 @@ func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, err return out, rows.Err() } -func (s *Store) ListOrganizations(ctx context.Context) ([]Organization, error) { +func (s *Store) ListOwners(ctx context.Context) ([]Owner, error) { rows, err := s.pool.Query(ctx, - `SELECT "id", "name" FROM "Organization" ORDER BY "name" ASC`) + `SELECT "id", "name", COALESCE("avatarUrl", '') FROM "Owner" ORDER BY "name" ASC`) if err != nil { return nil, err } defer rows.Close() - out := []Organization{} + out := []Owner{} for rows.Next() { - var o Organization - if err := rows.Scan(&o.ID, &o.Name); err != nil { + var o Owner + if err := rows.Scan(&o.ID, &o.Name, &o.AvatarURL); err != nil { return nil, err } out = append(out, o) @@ -113,10 +118,10 @@ func (s *Store) GetAdminCampaign(ctx context.Context, slug string) (*AdminCampai err := s.pool.QueryRow(ctx, ` SELECT "id", "slug", "title", "category", "summary", "art", "goalAmount", - "endsAt", "status"::text, "organizationId" + "endsAt", "status"::text, "ownerId" FROM "Campaign" WHERE "slug" = $1`, slug).Scan( &c.ID, &c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art, &c.GoalAmount, - &endsAt, &c.Status, &c.OrganizationID) + &endsAt, &c.Status, &c.OwnerID) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -171,18 +176,18 @@ type TierInput struct { } type CampaignInput struct { - ID string `json:"id"` - Slug string `json:"slug"` - Title string `json:"title"` - Category string `json:"category"` - Summary string `json:"summary"` - Art string `json:"art"` - GoalAmount int `json:"goalAmount"` - EndsAt string `json:"endsAt"` - Status string `json:"status"` - OrganizationID string `json:"organizationId"` - Story []StorySection `json:"story"` - Tiers []TierInput `json:"tiers"` + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Category string `json:"category"` + Summary string `json:"summary"` + Art string `json:"art"` + GoalAmount int `json:"goalAmount"` + EndsAt string `json:"endsAt"` + Status string `json:"status"` + OwnerID string `json:"ownerId"` + Story []StorySection `json:"story"` + Tiers []TierInput `json:"tiers"` } type SaveResult struct { @@ -218,10 +223,10 @@ func (s *Store) SaveCampaign(ctx context.Context, in CampaignInput) (*SaveResult tag, err := tx.Exec(ctx, ` UPDATE "Campaign" SET "slug"=$2, "title"=$3, "category"=$4, "summary"=$5, "art"=$6, "goalAmount"=$7, "endsAt"=$8, "status"=$9::"CampaignStatus", - "organizationId"=$10, "updatedAt"=now() + "ownerId"=$10, "updatedAt"=now() WHERE "id"=$1`, campaignID, in.Slug, in.Title, in.Category, in.Summary, in.Art, - in.GoalAmount, endsAt, in.Status, in.OrganizationID) + in.GoalAmount, endsAt, in.Status, in.OwnerID) if err != nil { if isUniqueViolation(err, "Campaign_slug_key") { return nil, ErrSlugTaken diff --git a/api/internal/store/campaigns.go b/api/internal/store/campaigns.go index 393246a..2be7d32 100644 --- a/api/internal/store/campaigns.go +++ b/api/internal/store/campaigns.go @@ -17,6 +17,9 @@ type Creator struct { Initials string `json:"initials"` Location string `json:"location"` Blurb string `json:"blurb"` + // Empty when the owner has no photo. The client falls back to Initials, + // so this is omitted from the payload rather than sent as "". + AvatarURL string `json:"avatarUrl,omitempty"` } type StorySection struct { @@ -98,11 +101,11 @@ func outTierType(s string) string { const listSQL = ` SELECT c."slug", c."title", c."category", c."summary", c."art", - o."name", o."initials", o."location", o."blurb", + o."name", o."initials", o."location", o."blurb", COALESCE(o."avatarUrl", ''), c."goalAmount", c."endsAt", c."status"::text, COALESCE(p."total", 0)::int, COALESCE(p."backers", 0)::int FROM "Campaign" c -JOIN "Organization" o ON o."id" = c."organizationId" +JOIN "Owner" o ON o."id" = c."ownerId" LEFT JOIN ( SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers" FROM "Pledge" @@ -127,6 +130,7 @@ func (s *Store) ListCampaigns(ctx context.Context) ([]CampaignSummary, error) { if err := rows.Scan( &c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art, &c.Creator.Name, &c.Creator.Initials, &c.Creator.Location, &c.Creator.Blurb, + &c.Creator.AvatarURL, &c.Goal, &endsAt, &status, &c.Raised, &c.Backers, ); err != nil { return nil, err @@ -140,13 +144,13 @@ func (s *Store) ListCampaigns(ctx context.Context) ([]CampaignSummary, error) { const campaignSQL = ` SELECT c."id", c."slug", c."title", c."category", c."summary", c."art", - o."name", o."initials", o."location", o."blurb", + o."name", o."initials", o."location", o."blurb", COALESCE(o."avatarUrl", ''), c."goalAmount", c."endsAt", c."status"::text, COALESCE(p."total", 0)::int, COALESCE(p."backers", 0)::int, (SELECT COUNT(*) FROM "CampaignUpdate" u WHERE u."campaignId" = c."id")::int, (SELECT COUNT(*) FROM "Comment" m WHERE m."campaignId" = c."id")::int FROM "Campaign" c -JOIN "Organization" o ON o."id" = c."organizationId" +JOIN "Owner" o ON o."id" = c."ownerId" LEFT JOIN ( SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers" FROM "Pledge" @@ -179,6 +183,7 @@ func (s *Store) GetCampaignBySlug(ctx context.Context, slug string) (*Campaign, err := s.pool.QueryRow(ctx, campaignSQL, slug, publicStatuses, countedPledges).Scan( &id, &c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art, &c.Creator.Name, &c.Creator.Initials, &c.Creator.Location, &c.Creator.Blurb, + &c.Creator.AvatarURL, &c.Goal, &endsAt, &status, &c.Raised, &c.Backers, &c.Updates, &c.Comments, ) if errors.Is(err, pgx.ErrNoRows) { diff --git a/api/internal/store/migrations/0002_organization_avatar.sql b/api/internal/store/migrations/0002_organization_avatar.sql new file mode 100644 index 0000000..b6e00e1 --- /dev/null +++ b/api/internal/store/migrations/0002_organization_avatar.sql @@ -0,0 +1,7 @@ +-- An owner's photo. Nullable: an organisation without one falls back to its +-- initials, which is what every card and campaign page already renders. +-- +-- A URL rather than bytes: there is no upload pipeline yet, and when there is, +-- it can write the location it stored the file at into this same column without +-- the read path changing. +ALTER TABLE "Organization" ADD COLUMN "avatarUrl" TEXT; diff --git a/api/internal/store/migrations/0003_owner_is_a_person.sql b/api/internal/store/migrations/0003_owner_is_a_person.sql new file mode 100644 index 0000000..9e9f186 --- /dev/null +++ b/api/internal/store/migrations/0003_owner_is_a_person.sql @@ -0,0 +1,11 @@ +-- A campaign's owner is always a person, never an organisation. The table was +-- named for the wrong idea, and every admin screen built on it would have +-- repeated the mistake, so the name goes rather than being worked around. +-- +-- Postgres keeps the old names for indexes and constraints through a rename; +-- they are renamed here too so nothing in the schema still says Organization. +ALTER TABLE "Organization" RENAME TO "Owner"; +ALTER TABLE "Campaign" RENAME COLUMN "organizationId" TO "ownerId"; + +ALTER INDEX "Organization_slug_key" RENAME TO "Owner_slug_key"; +ALTER INDEX "Organization_stripeAccountId_key" RENAME TO "Owner_stripeAccountId_key"; diff --git a/api/test/seed/insert.go b/api/test/seed/insert.go index 4cf0b6f..0a45286 100644 --- a/api/test/seed/insert.go +++ b/api/test/seed/insert.go @@ -10,10 +10,10 @@ import ( ) // reset clears the campaign side of the database. Relations cascade, so -// campaigns and organisations are enough; users go too because the seed's +// campaigns and owners are enough; users go too because the seed's // pledges are anonymous and any account left behind would be an orphan. func (s *seeder) reset(ctx context.Context) error { - for _, table := range []string{"Pledge", "Campaign", "Organization", "User"} { + for _, table := range []string{"Pledge", "Campaign", "Owner", "User"} { if _, err := s.pool.Exec(ctx, fmt.Sprintf(`DELETE FROM %q`, table)); err != nil { return err } @@ -35,24 +35,25 @@ func (s *seeder) campaign(ctx context.Context, c seeddata.Campaign) (int, int, e } defer tx.Rollback(ctx) - var orgID string + var ownerID string if err := tx.QueryRow(ctx, ` - INSERT INTO "Organization" ("id","slug","name","initials","location","blurb","approved") - VALUES ($1,$2,$3,$4,$5,$6,true) + INSERT INTO "Owner" ("id","slug","name","initials","location","blurb","avatarUrl","approved") + VALUES ($1,$2,$3,$4,$5,$6,NULLIF($7,''),true) ON CONFLICT ("slug") DO UPDATE SET "name" = EXCLUDED."name" RETURNING "id"`, - store.NewID(), c.Org.Slug, c.Org.Name, c.Org.Initials, c.Org.Location, c.Org.Blurb, - ).Scan(&orgID); err != nil { + store.NewID(), c.Owner.Slug, c.Owner.Name, c.Owner.Initials, c.Owner.Location, c.Owner.Blurb, + c.Owner.AvatarURL, + ).Scan(&ownerID); err != nil { return 0, 0, err } campaignID := store.NewID() if _, err := tx.Exec(ctx, ` INSERT INTO "Campaign" ("id","slug","title","category","summary","art","goalAmount", - "endsAt","status","organizationId") + "endsAt","status","ownerId") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::"CampaignStatus",$10)`, campaignID, c.Slug, c.Title, c.Category, c.Summary, c.Art, c.GoalAmount, - endsAt, c.Status, orgID); err != nil { + endsAt, c.Status, ownerID); err != nil { return 0, 0, err } diff --git a/api/test/seeddata/seed.json b/api/test/seeddata/seed.json index d980e52..75d3973 100644 --- a/api/test/seeddata/seed.json +++ b/api/test/seeddata/seed.json @@ -8,12 +8,13 @@ "goalAmount": 1200000, "endsAt": "2026-08-31", "status": "AKTIV", - "org": { - "slug": "zold-jozsefvaros", - "name": "Zöld Józsefváros Egyesület", - "initials": "ZJ", - "location": "Budapest VIII.", - "blurb": "2019 óta dolgozunk józsefvárosi zöldfelületekért. Eddig három kertet hoztunk létre, mindhárom ma is működik." + "owner": { + "slug": "gelencser-ferenc", + "name": "Gelencsér Ferenc", + "initials": "GF", + "location": "Budapest", + "blurb": "Momentumos politikus, a Rákóczi tér környékén nőtt fel. A kert ötlete egy lakossági fórumon született.", + "avatarUrl": "https://momentum.hu/wp-content/uploads/2022/10/Gelencser-Ferenc-portre-490.webp" }, "story": [ { @@ -117,12 +118,12 @@ "goalAmount": 2400000, "endsAt": "2026-09-15", "status": "AKTIV", - "org": { - "slug": "hangolo", - "name": "Hangoló Közhasznú Egyesület", - "initials": "HK", + "owner": { + "slug": "horvath-eszter", + "name": "Horváth Eszter", + "initials": "HE", "location": "Szeged", - "blurb": "Látássérült fiatalok tanulását segítjük 2016 óta. Eddig 62 hangoskönyvet készítettünk el." + "blurb": "Gyógypedagógus, 2016 óta készít hangoskönyveket látássérült diákoknak. Eddig 62 könyv készült el." }, "story": [ { @@ -197,12 +198,13 @@ "goalAmount": 3500000, "endsAt": "2026-10-04", "status": "AKTIV", - "org": { - "slug": "kerekagy", - "name": "Kerékagy Egyesület", - "initials": "KE", + "owner": { + "slug": "szarvas-koppany-bendeguz", + "name": "Szarvas Koppány Bendegúz", + "initials": "SZKB", "location": "Budapest VIII.", - "blurb": "Kerékpáros oktatást tartunk általános iskolákban. Tavaly 900 gyerek ült nálunk először nyeregbe." + "blurb": "Józsefvárosi önkormányzati képviselő volt. A tanpályát a Baross utcai iskolákkal együtt tervezi.", + "avatarUrl": "https://momentum.hu/wp-content/uploads/2024/02/Szarvas-Koppany-Bendeguz.jpg" }, "story": [ { @@ -279,12 +281,12 @@ "goalAmount": 1800000, "endsAt": "2026-07-31", "status": "LEZART", - "org": { - "slug": "koborlo", - "name": "Kóborló Társulat", - "initials": "KT", + "owner": { + "slug": "kovacs-marton", + "name": "Kovács Márton", + "initials": "KM", "location": "Debrecen", - "blurb": "Független társulat 2021 óta. Ott játszunk, ahol nincs kőszínház — eddig 38 településen." + "blurb": "Rendező. Ott játszik, ahol nincs kőszínház — eddig 38 településen fordult meg a társulattal." }, "story": [ { diff --git a/api/test/seeddata/seeddata.go b/api/test/seeddata/seeddata.go index 359bf0b..b15dc8f 100644 --- a/api/test/seeddata/seeddata.go +++ b/api/test/seeddata/seeddata.go @@ -14,12 +14,15 @@ import ( //go:embed seed.json var raw []byte -type Org struct { +type Owner struct { Slug string `json:"slug"` Name string `json:"name"` Initials string `json:"initials"` Location string `json:"location"` Blurb string `json:"blurb"` + // Optional. Left out of the fixture because there is nowhere to host the + // files yet; an organisation without one shows its initials. + AvatarURL string `json:"avatarUrl,omitempty"` } type Story struct { @@ -60,7 +63,7 @@ type Campaign struct { GoalAmount int `json:"goalAmount"` EndsAt string `json:"endsAt"` Status string `json:"status"` - Org Org `json:"org"` + Owner Owner `json:"owner"` Story []Story `json:"story"` Tiers []Tier `json:"tiers"` Updates []Update `json:"updates"` diff --git a/scripts/ci.sh b/scripts/ci.sh index 53b92be..ce35f2a 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -417,7 +417,7 @@ check_creation_refused() { -b /tmp/ci-admin.jar \ -H "Content-Type: application/json" \ -H "Origin: http://localhost:${APP_PORT}" \ - -d '{"title":"Uj kampany","slug":"uj-kampany","goalAmount":100000,"endsAt":"2027-01-01","organizationId":"x","status":"AKTIV"}' \ + -d '{"title":"Uj kampany","slug":"uj-kampany","goalAmount":100000,"endsAt":"2027-01-01","ownerId":"x","status":"AKTIV"}' \ "http://localhost:${APP_PORT}/api/admin/campaigns") [ "$code" = "404" ] || { echo "a save with no id returned $code, expected 404"; return 1; } # And it must not have landed anyway. @@ -428,6 +428,41 @@ check_creation_refused() { } step "campaigns cannot be created through the API" check_creation_refused +# Every campaign has an owner, and the owner may have a photo. The column is +# nullable and the client falls back to the initials chip, so both states have +# to survive the trip: set, and cleared again. +check_owner_photo() { + docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \ + -c "UPDATE \"Owner\" SET \"avatarUrl\" = 'https://example.invalid/o.jpg'" >/dev/null + + curl -s "http://localhost:${APP_PORT}/api/campaigns" | python3 -c ' +import json, sys +campaigns = json.load(sys.stdin) +assert campaigns, "no campaigns to check" +for c in campaigns: + assert c["creator"]["avatarUrl"] == "https://example.invalid/o.jpg", c["creator"] +' || return 1 + + # A future owner editor loads the photo from here. + curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/owners" | python3 -c ' +import json, sys +owners = json.load(sys.stdin) +assert owners, "no owners" +for o in owners: + assert o.get("avatarUrl"), o +' || return 1 + + docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \ + -c "UPDATE \"Owner\" SET \"avatarUrl\" = NULL" >/dev/null + + curl -s "http://localhost:${APP_PORT}/api/campaigns" | python3 -c ' +import json, sys +for c in json.load(sys.stdin): + assert "avatarUrl" not in c["creator"], "a missing photo must be omitted, not sent as an empty string" +' +} +step "owner photo round-trips, and is omitted when unset" check_owner_photo + check_404() { local code code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/kampanyok/nincs-ilyen") diff --git a/web/src/components/CampaignCard.tsx b/web/src/components/CampaignCard.tsx index c3f365d..b59b00d 100644 --- a/web/src/components/CampaignCard.tsx +++ b/web/src/components/CampaignCard.tsx @@ -1,5 +1,6 @@ import { Link } from "react-router"; import { CampaignArt } from "./CampaignArt"; +import { CreatorBadge } from "./CreatorBadge"; import { FundingLane } from "./FundingLane"; import { daysLeft, formatHuf, formatNumber, progressPct } from "@/lib/format"; import type { CampaignSummary } from "@/lib/types"; @@ -23,7 +24,9 @@ export function CampaignCard({ campaign }: { campaign: CampaignSummary }) {

{campaign.title}

-

{campaign.creator.name}

+
+ +

{campaign.summary}

diff --git a/web/src/components/CampaignForm.tsx b/web/src/components/CampaignForm.tsx index 0f94528..ab322e4 100644 --- a/web/src/components/CampaignForm.tsx +++ b/web/src/components/CampaignForm.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Link, useNavigate } from "react-router"; -import { ApiError, api, type AdminCampaign, type AdminTier, type Organization } from "@/lib/api"; +import { ApiError, api, type AdminCampaign, type AdminTier, type Owner } from "@/lib/api"; import { ART_KEYS, CATEGORIES, STATUSES, TIER_TYPES, readCampaignForm, serializeStory, } from "@/lib/campaign-form"; @@ -18,10 +18,10 @@ const ERRORS: Record = { */ export function CampaignForm({ campaign, - organizations, + owners, }: { campaign: AdminCampaign; - organizations: Organization[]; + owners: Owner[]; }) { const navigate = useNavigate(); const [notice, setNotice] = useState(); @@ -88,13 +88,13 @@ export function CampaignForm({ {CATEGORIES.map((c) => )} - + diff --git a/web/src/components/CreatorBadge.tsx b/web/src/components/CreatorBadge.tsx new file mode 100644 index 0000000..2c9a925 --- /dev/null +++ b/web/src/components/CreatorBadge.tsx @@ -0,0 +1,46 @@ +import type { Creator } from "@/lib/types"; + +/** + * The campaign owner: photo if the organisation has one, its initials if not. + * + * Both the card and the campaign page render the owner, and they used to do it + * with separate markup — the page had the avatar, the card had a bare line of + * muted text. One component so a photo appears in both places at once. + */ +export function CreatorBadge({ + creator, + size = "sm", + children, +}: { + creator: Creator; + size?: "sm" | "md"; + children?: React.ReactNode; +}) { + const box = size === "md" ? "size-14" : "size-11"; + + return ( +
+ {creator.avatarUrl ? ( + + ) : ( + + {creator.initials} + + )} + + {creator.name} + {children} + +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e13a066..8820ba0 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -67,7 +67,7 @@ export const api = { campaigns: () => request("/admin/campaigns"), campaign: (slug: string) => request(`/admin/campaigns/${encodeURIComponent(slug)}`), - organizations: () => request("/admin/organizations"), + owners: () => request("/admin/owners"), save: (input: CampaignInput) => request("/admin/campaigns", { method: "PUT", @@ -86,11 +86,11 @@ export interface AdminCampaignRow { title: string; status: string; goalAmount: number; - organization: string; + owner: string; pledgeCount: number; } -export interface Organization { +export interface Owner { id: string; name: string; } @@ -118,7 +118,7 @@ export interface AdminCampaign { goalAmount: number; endsAt: string; status: string; - organizationId: string; + ownerId: string; story: { heading: string; body: string[] }[]; tiers: AdminTier[]; } @@ -145,7 +145,7 @@ export interface CampaignInput { goalAmount: number; endsAt: string; status: string; - organizationId: string; + ownerId: string; story: { heading: string; body: string[] }[]; tiers: TierInput[]; } diff --git a/web/src/lib/campaign-form.ts b/web/src/lib/campaign-form.ts index 0e5598b..f442d20 100644 --- a/web/src/lib/campaign-form.ts +++ b/web/src/lib/campaign-form.ts @@ -149,7 +149,7 @@ export function readCampaignForm(form: FormData, rowCount: number): CampaignInpu goalAmount: parseAmount(form.get("goalAmount")) ?? 0, endsAt: String(form.get("endsAt") ?? ""), status: String(form.get("status") ?? "DRAFT"), - organizationId: String(form.get("organizationId") ?? ""), + ownerId: String(form.get("ownerId") ?? ""), story: parseStory(String(form.get("story") ?? "")), tiers: readTierRows(form, rowCount), }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index fd33112..4912ade 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -18,6 +18,8 @@ export interface Tier { export interface Creator { name: string; + /** Absent when the owner has no photo; the initials chip stands in. */ + avatarUrl?: string; initials: string; location: string; blurb: string; diff --git a/web/src/router.tsx b/web/src/router.tsx index 947b817..d199a1b 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,5 +1,5 @@ import { createBrowserRouter, redirect } from "react-router"; -import { ApiError, api, type AdminCampaign, type Organization } from "./lib/api"; +import { ApiError, api, type AdminCampaign, type Owner } from "./lib/api"; import { takeBootstrap } from "./lib/bootstrap"; import type { Campaign, CampaignSummary } from "./lib/types"; import type { PlatformStats } from "./lib/api"; @@ -68,11 +68,11 @@ export const router = createBrowserRouter([ path: "kampanyok/:slug", element: , loader: async ({ params }): Promise => { - const [campaign, organizations] = await Promise.all([ + const [campaign, owners] = await Promise.all([ guard(api.admin.campaign(params.slug!)), - guard(api.admin.organizations()), + guard(api.admin.owners()), ]); - return { campaign, organizations }; + return { campaign, owners }; }, }, ], @@ -84,7 +84,7 @@ export const router = createBrowserRouter([ export interface EditData { campaign: AdminCampaign; - organizations: Organization[]; + owners: Owner[]; } /** diff --git a/web/src/routes/CampaignPage.tsx b/web/src/routes/CampaignPage.tsx index 23572dd..494b2af 100644 --- a/web/src/routes/CampaignPage.tsx +++ b/web/src/routes/CampaignPage.tsx @@ -1,5 +1,6 @@ import { Link, useLoaderData } from "react-router"; import { CampaignArt } from "@/components/CampaignArt"; +import { CreatorBadge } from "@/components/CreatorBadge"; import { FundingLane } from "@/components/FundingLane"; import { TierCard } from "@/components/TierCard"; import { daysLeft, formatDate, formatHuf, formatNumber, progressPct } from "@/lib/format"; @@ -24,11 +25,10 @@ export default function CampaignPage() { {c.category}

{c.title}

-
- - {c.creator.initials} - - {c.creator.name} · {c.creator.location} +
+ + {` · ${c.creator.location}`} +
diff --git a/web/src/routes/admin/AdminHome.tsx b/web/src/routes/admin/AdminHome.tsx index fe31960..625dbbc 100644 --- a/web/src/routes/admin/AdminHome.tsx +++ b/web/src/routes/admin/AdminHome.tsx @@ -27,7 +27,7 @@ export default function AdminHome() { {c.title} - {c.organization} + {c.owner} {c.status} {formatHuf(c.goalAmount)} diff --git a/web/src/routes/admin/EditCampaign.tsx b/web/src/routes/admin/EditCampaign.tsx index 2e37924..8bf3f4c 100644 --- a/web/src/routes/admin/EditCampaign.tsx +++ b/web/src/routes/admin/EditCampaign.tsx @@ -5,14 +5,14 @@ import type { EditData } from "@/router"; /** Campaigns are created directly in the database; this only edits one. */ export default function EditCampaign() { - const { campaign, organizations } = useLoaderData() as EditData; + const { campaign, owners } = useLoaderData() as EditData; useTitle(campaign.title); return ( <>

{campaign.title}

- +