diff --git a/docs/TODO.md b/docs/TODO.md
new file mode 100644
index 0000000..6ea11d8
--- /dev/null
+++ b/docs/TODO.md
@@ -0,0 +1,13 @@
+# Goondex TODO / DONE
+
+## TODO
+- [ ] Implement bulk studio import (`./goondex import all-studios`) with the same pagination/resume flow as the performer importer.
+- [ ] Implement bulk scene import (`./goondex import all-scenes`) and wire the CLI/UI to the new data set.
+- [ ] Build a movie ingest path (TPDB and/or Adult Empire) that feeds the `movies` tables and populates the movies pages.
+- [ ] Align the web stack on a single CSS pipeline (deprecate legacy `style.css`, keep goondex + scoped component files).
+- [ ] Add lightweight UI validation (lint/smoke tests) for navigation, modals, and search to catch regressions early.
+
+## DONE
+- [x] Split card styling into per-context files (base, performers, studios, scenes) and updated listing templates to use them.
+- [x] Created shared task lists (`docs/TODO.md`, `docs/WEB_TODO.md`) to keep engineering and web work in sync.
+- [x] Adult Empire scraper + TPDB merge support for performers (see `SESSION_SUMMARY_v0.1.0-dev4.md`).
diff --git a/docs/WEB_TODO.md b/docs/WEB_TODO.md
new file mode 100644
index 0000000..c196fb1
--- /dev/null
+++ b/docs/WEB_TODO.md
@@ -0,0 +1,11 @@
+# Web TODO / DONE
+
+## TODO
+- [ ] Split remaining shared CSS (hero, navbar, search, stats, forms, modals) into scoped files and retire inline styles in templates.
+- [ ] Migrate detail pages to the same CSS pipeline as listings (drop `style.css` in favor of scoped goondex files).
+- [ ] Audit cards for movies once movie data lands and add a dedicated `cards-movie.css`.
+- [ ] Add a short usage note for the GX component set (which classes/components we rely on and where).
+
+## DONE
+- [x] Added per-context card styles (`cards/card-base.css`, `cards/cards-performer.css`, `cards/cards-studio.css`, `cards/cards-scene.css`) and wired listing templates.
+- [x] Cleaned up CSS imports in `internal/web/static/css/goondex.css` to reference real files only.
diff --git a/internal/web/server.go b/internal/web/server.go
index e62e376..2d82ae4 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -6,11 +6,11 @@ import (
"encoding/json"
"fmt"
"html/template"
+ "io/fs"
"net/http"
"os"
"strconv"
"time"
- "io/fs"
"git.leaktechnologies.dev/stu/Goondex/internal/db"
import_service "git.leaktechnologies.dev/stu/Goondex/internal/import"
@@ -59,10 +59,10 @@ func (s *Server) Start() error {
mux.Handle(
"/static/",
- http.StripPrefix(
- "/static/",
- http.FileServer(http.FS(staticFS)),
- ),
+ http.StripPrefix(
+ "/static/",
+ http.FileServer(http.FS(staticFS)),
+ ),
)
// ============================================================================
@@ -127,6 +127,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
movies, _ := movieStore.Search("")
data := map[string]interface{}{
+ "PageTitle": "Dashboard",
+ "ActivePage": "dashboard",
"PerformerCount": len(performers),
"StudioCount": len(studios),
"SceneCount": len(scenes),
@@ -218,10 +220,12 @@ func (s *Server) handlePerformerList(w http.ResponseWriter, r *http.Request) {
}
data := map[string]interface{}{
- "Performers": performersWithCounts,
- "Query": query,
- "Nationalities": nationalities,
- "Genders": genders,
+ "PageTitle": "Performers",
+ "ActivePage": "performers",
+ "Performers": performersWithCounts,
+ "Query": query,
+ "Nationalities": nationalities,
+ "Genders": genders,
"SelectedNationality": nationalityFilter,
"SelectedGender": genderFilter,
}
@@ -317,9 +321,12 @@ func (s *Server) handlePerformerDetail(w http.ResponseWriter, r *http.Request) {
scenes, _ := sceneStore.GetByPerformer(id)
data := map[string]interface{}{
- "Performer": performer,
- "SceneCount": sceneCount,
- "Scenes": scenes,
+ "PageTitle": fmt.Sprintf("Performer: %s", performer.Name),
+ "ActivePage": "performers",
+ "Stylesheets": []string{"/static/css/style.css", "/static/css/goondex.css"},
+ "Performer": performer,
+ "SceneCount": sceneCount,
+ "Scenes": scenes,
}
s.templates.ExecuteTemplate(w, "performer_detail.html", data)
@@ -344,12 +351,14 @@ func (s *Server) handleStudioList(w http.ResponseWriter, r *http.Request) {
for _, st := range studios {
count, _ := store.GetSceneCount(st.ID)
studiosWithCounts = append(studiosWithCounts,
- StudioWithCount{Studio: st, SceneCount: count})
+ StudioWithCount{Studio: st, SceneCount: count})
}
data := map[string]interface{}{
- "Studios": studiosWithCounts,
- "Query": query,
+ "PageTitle": "Studios",
+ "ActivePage": "studios",
+ "Studios": studiosWithCounts,
+ "Query": query,
}
s.templates.ExecuteTemplate(w, "studios.html", data)
@@ -373,8 +382,11 @@ func (s *Server) handleStudioDetail(w http.ResponseWriter, r *http.Request) {
sceneCount, _ := store.GetSceneCount(id)
data := map[string]interface{}{
- "Studio": studio,
- "SceneCount": sceneCount,
+ "PageTitle": fmt.Sprintf("Studio: %s", studio.Name),
+ "ActivePage": "studios",
+ "Stylesheets": []string{"/static/css/style.css", "/static/css/goondex.css"},
+ "Studio": studio,
+ "SceneCount": sceneCount,
}
s.templates.ExecuteTemplate(w, "studio_detail.html", data)
@@ -408,12 +420,14 @@ func (s *Server) handleSceneList(w http.ResponseWriter, r *http.Request) {
}
scenesWithStudios = append(scenesWithStudios,
- SceneWithStudio{Scene: sc, StudioName: studioName})
+ SceneWithStudio{Scene: sc, StudioName: studioName})
}
data := map[string]interface{}{
- "Scenes": scenesWithStudios,
- "Query": query,
+ "PageTitle": "Scenes",
+ "ActivePage": "scenes",
+ "Scenes": scenesWithStudios,
+ "Query": query,
}
s.templates.ExecuteTemplate(w, "scenes.html", data)
@@ -448,11 +462,14 @@ func (s *Server) handleSceneDetail(w http.ResponseWriter, r *http.Request) {
}
data := map[string]interface{}{
- "Scene": scene,
- "Performers": performers,
- "Tags": tags,
- "Movies": movies,
- "StudioName": studioName,
+ "PageTitle": fmt.Sprintf("Scene: %s", scene.Title),
+ "ActivePage": "scenes",
+ "Stylesheets": []string{"/static/css/style.css", "/static/css/goondex.css"},
+ "Scene": scene,
+ "Performers": performers,
+ "Tags": tags,
+ "Movies": movies,
+ "StudioName": studioName,
}
s.templates.ExecuteTemplate(w, "scene_detail.html", data)
@@ -492,8 +509,10 @@ func (s *Server) handleMovieList(w http.ResponseWriter, r *http.Request) {
}
data := map[string]interface{}{
- "Movies": moviesWithDetails,
- "Query": query,
+ "PageTitle": "Movies",
+ "ActivePage": "movies",
+ "Movies": moviesWithDetails,
+ "Query": query,
}
s.templates.ExecuteTemplate(w, "movies.html", data)
@@ -526,6 +545,8 @@ func (s *Server) handleMovieDetail(w http.ResponseWriter, r *http.Request) {
}
data := map[string]interface{}{
+ "PageTitle": fmt.Sprintf("Movie: %s", movie.Title),
+ "ActivePage": "movies",
"Movie": movie,
"Scenes": scenes,
"StudioName": studioName,
@@ -594,7 +615,7 @@ func (s *Server) handleAPIImportPerformer(w http.ResponseWriter, r *http.Request
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Imported %d performer(s)", imported),
- Data: map[string]int{"imported": imported, "found": len(performers)},
+ Data: map[string]int{"imported": imported, "found": len(performers)},
})
}
@@ -644,7 +665,7 @@ func (s *Server) handleAPIImportStudio(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Imported %d studio(s)", imported),
- Data: map[string]int{"imported": imported, "found": len(studios)},
+ Data: map[string]int{"imported": imported, "found": len(studios)},
})
}
@@ -737,7 +758,7 @@ func (s *Server) handleAPIImportScene(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Imported %d scene(s)", imported),
- Data: map[string]int{"imported": imported, "found": len(scenes)},
+ Data: map[string]int{"imported": imported, "found": len(scenes)},
})
}
@@ -856,7 +877,7 @@ func (s *Server) handleAPIBulkImportPerformers(w http.ResponseWriter, r *http.Re
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Imported %d/%d performers", result.Imported, result.Total),
- Data: result,
+ Data: result,
})
}
@@ -886,7 +907,7 @@ func (s *Server) handleAPIBulkImportStudios(w http.ResponseWriter, r *http.Reque
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Imported %d/%d studios", result.Imported, result.Total),
- Data: result,
+ Data: result,
})
}
@@ -916,7 +937,7 @@ func (s *Server) handleAPIBulkImportScenes(w http.ResponseWriter, r *http.Reques
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Imported %d/%d scenes", result.Imported, result.Total),
- Data: result,
+ Data: result,
})
}
@@ -1084,6 +1105,6 @@ func (s *Server) handleAPIGlobalSearch(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Found %d results", results["total"]),
- Data: results,
+ Data: results,
})
}
diff --git a/internal/web/static/css/cards/card-base.css b/internal/web/static/css/cards/card-base.css
new file mode 100644
index 0000000..f0a0bad
--- /dev/null
+++ b/internal/web/static/css/cards/card-base.css
@@ -0,0 +1,103 @@
+/*
+ * GOONDEX CARD BASE
+ * Shared grid + card shell used by performer/studio/scene listings.
+ */
+
+:root {
+ --gx-card-thumb-ratio: 3 / 4;
+ --gx-card-min-width: 250px;
+}
+
+.gx-card-grid {
+ display: grid;
+ gap: 1.6rem;
+ padding: 1rem 0;
+ grid-template-columns: repeat(auto-fill, minmax(var(--gx-card-min-width), 1fr));
+}
+
+.gx-card {
+ background: var(--color-bg-card);
+ border: 1px solid var(--color-border-soft);
+ border-radius: var(--radius-soft);
+ overflow: hidden;
+
+ box-shadow: var(--shadow-elevated);
+ transition:
+ transform var(--transition),
+ box-shadow var(--transition),
+ border-color var(--transition);
+
+ cursor: pointer;
+ position: relative;
+}
+
+.gx-card:hover {
+ transform: translateY(-4px);
+ border-color: var(--color-brand);
+ box-shadow:
+ 0 0 18px rgba(255, 79, 163, 0.28),
+ 0 6px 24px rgba(0, 0, 0, 0.55);
+}
+
+.gx-card-thumb {
+ width: 100%;
+ aspect-ratio: var(--gx-card-thumb-ratio);
+ background-size: cover;
+ background-position: center;
+ filter: brightness(0.92);
+ transition: filter var(--transition-fast);
+}
+
+.gx-card:hover .gx-card-thumb {
+ filter: brightness(1);
+}
+
+.gx-card-body {
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.gx-card-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+
+ background: linear-gradient(135deg, var(--color-text-primary), var(--color-header));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+.gx-card-meta {
+ font-size: 0.85rem;
+ color: var(--color-text-secondary);
+ opacity: 0.9;
+}
+
+.gx-card-tags {
+ margin-top: 0.7rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+
+.gx-card-tag {
+ padding: 0.2rem 0.55rem;
+ font-size: 0.75rem;
+ border-radius: var(--radius);
+ background: rgba(255, 79, 163, 0.08);
+ color: var(--color-brand);
+ border: 1px solid rgba(255, 79, 163, 0.25);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+@media (max-width: 550px) {
+ .gx-card-grid {
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
+ }
+
+ .gx-card-title {
+ font-size: 1rem;
+ }
+}
diff --git a/internal/web/static/css/cards/cards-movie.css b/internal/web/static/css/cards/cards-movie.css
new file mode 100644
index 0000000..0882d95
--- /dev/null
+++ b/internal/web/static/css/cards/cards-movie.css
@@ -0,0 +1,16 @@
+/*
+ * GOONDEX MOVIE CARDS
+ * Poster-focused layout for movie listings.
+ */
+
+.movie-card-grid {
+ --gx-card-thumb-ratio: 2 / 3;
+}
+
+.movie-card .gx-card-meta + .gx-card-meta {
+ margin-top: 0.25rem;
+}
+
+.movie-card .gx-card-tags {
+ margin-top: 0.6rem;
+}
diff --git a/internal/web/static/css/cards/cards-performer.css b/internal/web/static/css/cards/cards-performer.css
new file mode 100644
index 0000000..b5e5fe9
--- /dev/null
+++ b/internal/web/static/css/cards/cards-performer.css
@@ -0,0 +1,16 @@
+/*
+ * GOONDEX PERFORMER CARDS
+ * Portrait-focused layout for performer listings.
+ */
+
+.performer-card-grid {
+ --gx-card-thumb-ratio: 3 / 4;
+}
+
+.performer-card .gx-card-meta + .gx-card-meta {
+ margin-top: 0.3rem;
+}
+
+.performer-card .gx-card-tags {
+ margin-top: 0.6rem;
+}
diff --git a/internal/web/static/css/cards/cards-scene.css b/internal/web/static/css/cards/cards-scene.css
new file mode 100644
index 0000000..e8361ec
--- /dev/null
+++ b/internal/web/static/css/cards/cards-scene.css
@@ -0,0 +1,16 @@
+/*
+ * GOONDEX SCENE CARDS
+ * Landscape-focused layout for scene listings.
+ */
+
+.scene-card-grid {
+ --gx-card-thumb-ratio: 16 / 9;
+}
+
+.scene-card .gx-card-meta + .gx-card-meta {
+ margin-top: 0.25rem;
+}
+
+.scene-card .gx-card-tags {
+ margin-top: 0.6rem;
+}
diff --git a/internal/web/static/css/cards/cards-studio.css b/internal/web/static/css/cards/cards-studio.css
new file mode 100644
index 0000000..afeaaa7
--- /dev/null
+++ b/internal/web/static/css/cards/cards-studio.css
@@ -0,0 +1,23 @@
+/*
+ * GOONDEX STUDIO CARDS
+ * Studio listings with compact description support.
+ */
+
+.studio-card-grid {
+ --gx-card-thumb-ratio: 3 / 4;
+}
+
+.studio-card .studio-card-description {
+ margin-top: 0.5rem;
+ font-size: 0.85rem;
+ color: var(--color-text-secondary);
+ opacity: 0.85;
+ line-height: 1.4;
+ max-height: 2.8rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.studio-card .gx-card-tags {
+ margin-top: 0.6rem;
+}
diff --git a/internal/web/static/css/goondex.css b/internal/web/static/css/goondex.css
index 9b2a5e1..ed06e39 100644
--- a/internal/web/static/css/goondex.css
+++ b/internal/web/static/css/goondex.css
@@ -5,7 +5,6 @@
/* ===== GX COMPONENT LIBRARY ================================================= */
@import 'gx/GX_Button.css';
-@import 'gx/GX_CardGrid.css';
@import 'gx/GX_Checkbox.css';
@import 'gx/GX_Input.css';
@import 'gx/GX_Loader.css';
@@ -15,18 +14,18 @@
/* ===== LAYOUT & STRUCTURE =================================================== */
@import 'layout.css';
-@import 'navbar.css';
-@import 'sidepanels.css';
-/* ===== PAGE-LEVEL COMPONENTS ================================================ */
-@import 'hero.css';
-@import 'stats.css';
+/* ===== CORE COMPONENTS ===================================================== */
@import 'forms.css';
@import 'buttons.css';
@import 'components.css';
+/* ===== CARDS (SCOPED BY CONTEXT) =========================================== */
+@import 'cards/card-base.css';
+@import 'cards/cards-performer.css';
+@import 'cards/cards-studio.css';
+@import 'cards/cards-scene.css';
+@import 'cards/cards-movie.css';
+
/* ===== GLOBAL PAGE STYLES =================================================== */
@import 'pages.css';
-
-/* ===== RESPONSIVE OVERRIDES (MOBILE/TABLET/HALF-SCREEN) ===================== */
-@import 'responsive.css';
diff --git a/internal/web/static/css/gx/GX_CardGrid.css b/internal/web/static/css/gx/GX_CardGrid.css
index b62155b..8b54b1f 100644
--- a/internal/web/static/css/gx/GX_CardGrid.css
+++ b/internal/web/static/css/gx/GX_CardGrid.css
@@ -1,103 +1,11 @@
/*
- * GX CARD GRID — Performer / Studio / Scene cards
- * Dark luxury aesthetic, Flamingo Pink medium glow, responsive columns
+ * GX CARD GRID (COMPAT)
+ * This file now re-exports the scoped card styles.
+ * Prefer importing /static/css/cards/*.css directly.
*/
-/* WRAPPER */
-.gx-card-grid {
- display: grid;
- gap: 1.6rem;
- padding: 1rem 0;
- grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
-}
-
-/* CARD */
-.gx-card {
- background: var(--color-bg-card);
- border: 1px solid var(--color-border-soft);
- border-radius: var(--radius-soft);
- overflow: hidden;
-
- box-shadow: var(--shadow-elevated);
- transition: transform var(--transition),
- box-shadow var(--transition),
- border-color var(--transition);
-
- cursor: pointer;
- position: relative;
-}
-
-/* HOVER EFFECT */
-.gx-card:hover {
- transform: translateY(-4px);
- border-color: var(--color-brand);
- box-shadow: 0 0 18px rgba(255, 79, 163, 0.28),
- 0 6px 24px rgba(0, 0, 0, 0.55);
-}
-
-/* THUMBNAIL */
-.gx-card-thumb {
- width: 100%;
- aspect-ratio: 3 / 4;
- background-size: cover;
- background-position: center;
- filter: brightness(0.92);
- transition: filter var(--transition-fast);
-}
-
-.gx-card:hover .gx-card-thumb {
- filter: brightness(1);
-}
-
-/* CONTENT */
-.gx-card-body {
- padding: 1rem;
-}
-
-/* TITLE */
-.gx-card-title {
- font-size: 1.1rem;
- font-weight: 600;
- margin-bottom: 0.35rem;
-
- background: linear-gradient(135deg, var(--color-text-primary), var(--color-header));
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
-}
-
-/* SMALL META (scene count, category, etc.) */
-.gx-card-meta {
- font-size: 0.85rem;
- color: var(--color-text-secondary);
- opacity: 0.9;
-}
-
-/* TAGS inside cards (optional) */
-.gx-card-tags {
- margin-top: 0.8rem;
- display: flex;
- flex-wrap: wrap;
- gap: 0.4rem;
-}
-
-.gx-card-tag {
- padding: 0.2rem 0.55rem;
- font-size: 0.75rem;
- border-radius: var(--radius);
- background: rgba(255, 79, 163, 0.08);
- color: var(--color-brand);
- border: 1px solid rgba(255, 79, 163, 0.25);
- text-transform: uppercase;
- letter-spacing: 0.03em;
-}
-
-/* MOBILE OPTIMISATION */
-@media (max-width: 550px) {
- .gx-card-grid {
- grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
- }
-
- .gx-card-title {
- font-size: 1rem;
- }
-}
+@import '../cards/card-base.css';
+@import '../cards/cards-performer.css';
+@import '../cards/cards-studio.css';
+@import '../cards/cards-scene.css';
+@import '../cards/cards-movie.css';
diff --git a/internal/web/static/css/gx/GX_CardGrid.html b/internal/web/static/css/gx/GX_CardGrid.html
index de8ce1a..0bff653 100644
--- a/internal/web/static/css/gx/GX_CardGrid.html
+++ b/internal/web/static/css/gx/GX_CardGrid.html
@@ -1,7 +1,7 @@
-