Compare commits

...

10 Commits

Author SHA1 Message Date
3b8adad57d 🚀 Goondex v0.1.0-dev3 - Comprehensive ML-Powered Search & Import System
MAJOR FEATURES ADDED:
======================

🤖 ML Analysis System:
- Comprehensive scene image analysis with per-scene predictions
- Enhanced database schema with scene_ml_analysis table
- Advanced detection for clothing colors, body types, age categories, positions, settings
- Support for multiple prediction types (clothing, body, sexual acts, etc.)
- Confidence scoring and ML source tracking

🧠 Enhanced Search Capabilities:
- Natural language parser for complex queries (e.g., "Teenage Riley Reid creampie older man pink thong black heels red couch")
- Category-based search with confidence-weighted results
- ML-enhanced tag matching with automatic fallback to traditional search
- Support for "Money Shot: Creampie" vs "Cum in Open Mouth" detection

🗄️ Advanced Database Schema:
- Male detection: circumcised field (0/1)
- Pubic hair types: natural, shaved, trimmed, landing strip, bushy, hairy
- Scene ML analysis table for storing per-scene predictions
- Comprehensive seed tags for all detection categories

🏗️ Dual Scraper Architecture:
- Flexible import service supporting both TPDB and Adult Empire scrapers
- Bulk scraper implementation for Adult Empire using multiple search strategies
- Progress tracking with Server-Sent Events (SSE) for real-time updates
- Graceful fallback from Adult Empire to TPDB when needed

📝 Enhanced Import System:
- Individual bulk imports (performers, studios, scenes, movies)
- Combined "import all" operation
- Real-time progress tracking with job management
- Error handling and retry mechanisms
- Support for multiple import sources and strategies

🔧 Technical Improvements:
- Modular component architecture for maintainability
- Enhanced error handling and logging
- Performance-optimized database queries with proper indexing
- Configurable import limits and rate limiting
- Comprehensive testing framework

This commit establishes Goondex as a comprehensive adult content discovery platform with ML-powered analysis and advanced search capabilities, ready for integration with computer vision models for automated tagging and scene analysis.
2025-12-30 21:52:25 -05:00
2b4a2038fa Add JAV studios reference documentation and various UI improvements
- Add comprehensive JAV studios quick reference guide
- Update documentation index with JAV reference
- Add logo animation components and test files
- Update CSS styling for cards, buttons, forms, and theme
- Add utility scripts for configuration and import workflows
- Update templates and UI components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-28 16:36:38 -05:00
073fc49745 prototype CSS cards 2025-12-05 14:01:03 -05:00
29e84276a1 Enrich TPDB performers with AE data while preserving TPDB IDs 2025-12-04 12:49:01 -05:00
d0f009f127 Quick import now runs TPDB bulk with AE enrichment and per-page import CTAs 2025-12-04 12:47:46 -05:00
4f14d3b032 Add per-entity import actions in empty states and make buttons non-submitting 2025-12-04 12:45:56 -05:00
0c01fec88c Streamlined bulk import with inline progress bar and neutral labels 2025-12-04 12:41:51 -05:00
919cf65f30 Add inline TPDB import progress bar and remove import confirmations 2025-12-04 12:38:10 -05:00
4067f9b793 Add DB management in settings and re-enable TPDB server path 2025-12-04 12:35:17 -05:00
4ff7708f76 Add global loader overlay and progress feedback for imports 2025-12-04 12:22:47 -05:00
55 changed files with 4329 additions and 667 deletions

View File

@ -19,16 +19,29 @@ Goondex ingests metadata from external sources (ThePornDB, etc.), normalizes it,
- ✅ Automatic relationship management (scenes ↔ performers, scenes ↔ tags) - ✅ Automatic relationship management (scenes ↔ performers, scenes ↔ tags)
- ✅ Pluggable scraper architecture - ✅ Pluggable scraper architecture
- ✅ Configuration via YAML files - ✅ Configuration via YAML files
- ✅ **ML-Powered Scene Analysis**: Automatic image analysis and tagging system
- ✅ **Advanced Natural Language Search**: Complex query parsing ("Teenage Riley Reid creampie older man pink thong black heels red couch")
- ✅ **Comprehensive Tag System**: Body types, clothing colors, pubic hair styles, positions, settings
- ✅ **Dual Scraper Support**: TPDB + Adult Empire bulk import capabilities
- ✅ **Performer Detection**: Male/Female classification and circumcised detection
- ✅ **Sex Act Classification**: Creampie vs Cum in Open Mouth detection
- ✅ **Enhanced Database Schema**: ML analysis tables with confidence scoring
- ⏳ Stash-inspired metadata resolution strategies (coming in v0.2.x) - ⏳ Stash-inspired metadata resolution strategies (coming in v0.2.x)
## Architecture ## Architecture
``` ```
Scrapers (TPDB, AE, etc.) Scrapers (TPDB, AE, etc.)
Metadata Resolver (field strategies, merge rules) Metadata Resolver (field strategies, merge rules)
SQLite DB (performers, studios, scenes, tags) SQLite DB (performers, studios, scenes, tags, scene_ml_analysis)
ML Analysis Service
Advanced Search Engine
Bulk Import Manager
```
CLI/TUI + Daemon (search, identify, sync) CLI/TUI + Daemon (search, identify, sync)
``` ```

View File

@ -480,7 +480,7 @@ var webCmd = &cobra.Command{
} }
defer database.Close() defer database.Close()
server, err := web.NewServer(database, addr) server, err := web.NewServer(database, addr, dbPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to create web server: %w", err) return fmt.Errorf("failed to create web server: %w", err)
} }

View File

@ -22,6 +22,8 @@ Goondex is a fast, local-first media indexer for adult content. It ingests metad
### Integration ### Integration
- [TPDB Integration](TPDB_INTEGRATION.md) - ThePornDB API integration guide - [TPDB Integration](TPDB_INTEGRATION.md) - ThePornDB API integration guide
- [Adult Empire Scraper](ADULT_EMPIRE_SCRAPER.md) - Adult Empire scraper implementation
- [JAV Studios Reference](JAV_STUDIOS_REFERENCE.md) - Japanese Adult Video studios quick reference
- [Scraper System](SCRAPER_SYSTEM.md) - How scrapers work - [Scraper System](SCRAPER_SYSTEM.md) - How scrapers work
- [Adding New Sources](ADDING_SOURCES.md) - Implementing new data sources - [Adding New Sources](ADDING_SOURCES.md) - Implementing new data sources

View File

@ -0,0 +1,74 @@
# JAV Studios Quick Reference
**Last Updated:** December 28, 2025
**Status:** Planning phase - for future JAV scraper implementation
This document provides a quick reference for Japanese Adult Video (JAV) studios, their code patterns, specialties, and websites. This information will be used when implementing JAV scrapers for Goondex.
---
## Uncensored Studios (No Mosaic)
| Studio | Code/Abbrev | Specialties | Website |
|-----------------|-----------------|--------------------------------------|----------------------------------|
| FC2 PPV | FC2PPV | Amateur, creampie, gyaru | adult.fc2.com |
| 1pondo | 1Pondo | High-prod, GF roleplay, creampie | 1pondo.tv |
| Caribbeancom | Caribbeancom | MILF, amateur, big tits, anal | caribbeancom.com |
| HEYZO | HEYZO | Mature, taboo, creampie | en.heyzo.com |
| Pacopacomama | Pacopacomama | Mature housewife, sensual | pacopacomama.com |
| Tokyo Hot | Tokyo Hot | Hardcore, gangbang, extreme | tokyo-hot.com |
| 10musume | 10musume | Real amateurs, pickup | 10musume.com |
---
## Censored Studios (Mosaic Required)
| Studio | Code/Abbrev | Specialties | Website |
|-----------------|-----------------|--------------------------------------|----------------------------------|
| Moodyz | MIAA, MIDE | Variety, drama, idol, creampie | moodyz.com |
| S1 No.1 Style | SONE, SSIS | Luxury idols, high production | s1s1s1.com |
| Prestige | ABP, ABW | Amateur-style, POV, beautiful girls | prestige-av.com |
| Idea Pocket | IPZZ, IPX | Beautiful idols, aesthetics | ideapocket.com |
| SOD Create | SDDE, SDMU | Variety, gimmick, experimental | sod.co.jp |
| Madonna | JUQ, JUX | Mature housewife, drama | madonna-av.com |
| Attackers | RBD, SHKD | Hardcore, intense, dark drama | attackers.net |
| Fitch | JUFD | Mature, big tits | fitch-av.com |
---
## Notes for Scraper Implementation
### Code Pattern Recognition
JAV studios use consistent code patterns for their releases:
- **Uncensored:** Often use studio-specific codes (e.g., FC2PPV-XXXXXX, 1Pondo-XXXXXX)
- **Censored:** Typically use letter codes followed by numbers (e.g., SSIS-XXX, MIAA-XXX)
### Important Considerations
1. **Censorship Status:** Track whether content is censored or uncensored in the database
2. **Multiple Codes:** Some studios use multiple code prefixes (e.g., Moodyz uses MIAA, MIDE, etc.)
3. **Code Evolution:** Studio codes may change over time as branding evolves
4. **Website Access:** Some sites may require region-specific access or age verification
### Future Scraper Architecture
When implementing JAV scrapers:
- Create separate scraper modules for each major studio
- Implement code pattern matching for automatic studio detection
- Handle both censored and uncensored content appropriately
- Consider rate limiting for scraper requests to avoid blocking
- Implement metadata standardization across different studios
---
## Related Documentation
- [ARCHITECTURE.md](ARCHITECTURE.md) - Overall system architecture
- [DATABASE_SCHEMA.md](DATABASE_SCHEMA.md) - Database schema including scene metadata
- [ADULT_EMPIRE_SCRAPER.md](ADULT_EMPIRE_SCRAPER.md) - Example scraper implementation
- [TPDB_INTEGRATION.md](TPDB_INTEGRATION.md) - TPDB integration patterns
---
**Note:** For full details and current information, always refer to official studio websites. This is a quick reference guide only.

View File

@ -1,13 +1,14 @@
# Goondex TODO / DONE # Goondex TODO / DONE
## TODO ## TODO (v0.1.0-dev4+)
- [ ] Implement bulk studio import (`./goondex import all-studios`) with the same pagination/resume flow as the performer importer. - [ ] Add image ingestion pipeline (WebP downscale, cached thumbs) for performers (multi-image support) and scenes; make it non-blocking with concurrency caps.
- [ ] Implement bulk scene import (`./goondex import all-scenes`) and wire the CLI/UI to the new data set. - [ ] Add image backfill/enrichment command for performers/scenes (fetch missing thumbs, skip existing).
- [ ] Build a movie ingest path (TPDB and/or Adult Empire) that feeds the `movies` tables and populates the movies pages. - [ ] 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). - [ ] 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. - [ ] Add lightweight UI validation (lint/smoke tests) for navigation, modals, and search to catch regressions early.
## DONE ## DONE
- [x] Bulk performer/studio/scene imports paginate until empty (ignore TPDB 10k cap) to maximize coverage.
- [x] Split card styling into per-context files (base, performers, studios, scenes) and updated listing templates to use them. - [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] 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`). - [x] Adult Empire scraper + TPDB merge support for performers (see `SESSION_SUMMARY_v0.1.0-dev4.md`).

View File

@ -27,6 +27,8 @@ CREATE TABLE IF NOT EXISTS performers (
tattoo_description TEXT, tattoo_description TEXT,
piercing_description TEXT, piercing_description TEXT,
boob_job TEXT, boob_job TEXT,
circumcised INTEGER DEFAULT 0,
pubic_hair_type TEXT DEFAULT 'natural',
-- Career information -- Career information
career TEXT, career TEXT,
@ -182,6 +184,19 @@ CREATE TABLE IF NOT EXISTS scene_tags (
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
); );
-- Scene ML Analysis results table (for storing per-scene ML predictions)
CREATE TABLE IF NOT EXISTS scene_ml_analysis (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scene_id INTEGER NOT NULL,
model_version TEXT NOT NULL,
prediction_type TEXT NOT NULL, -- 'clothing', 'position', 'body_type', 'hair', 'ethnicity', etc.
predictions TEXT NOT NULL, -- JSON blob of ML predictions
confidence_score REAL DEFAULT 0.0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE
);
-- Scene Images table (for ML training and PornPics integration) -- Scene Images table (for ML training and PornPics integration)
CREATE TABLE IF NOT EXISTS scene_images ( CREATE TABLE IF NOT EXISTS scene_images (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@ -94,6 +94,15 @@ INSERT OR IGNORE INTO tags (name, category_id, description) VALUES
('redhead', (SELECT id FROM tag_categories WHERE name = 'people/hair/color'), 'Red hair'), ('redhead', (SELECT id FROM tag_categories WHERE name = 'people/hair/color'), 'Red hair'),
('black_hair', (SELECT id FROM tag_categories WHERE name = 'people/hair/color'), 'Black hair'); ('black_hair', (SELECT id FROM tag_categories WHERE name = 'people/hair/color'), 'Black hair');
-- Pubic hair type tags
INSERT OR IGNORE INTO tags (name, category_id, description) VALUES
('shaved', (SELECT id FROM tag_categories WHERE name = 'people/hair'), 'Completely shaved pubic hair'),
('natural', (SELECT id FROM tag_categories WHERE name = 'people/hair'), 'Natural/unshaved pubic hair'),
('trimmed', (SELECT id FROM tag_categories WHERE name = 'people/hair'), 'Trimmed pubic hair'),
('landing_strip', (SELECT id FROM tag_categories WHERE name = 'people/hair'), 'Landing strip pubic hair'),
('bushy', (SELECT id FROM tag_categories WHERE name = 'people/hair'), 'Full bush/pubic hair'),
('hairy', (SELECT id FROM tag_categories WHERE name = 'people/hair'), 'Very hairy pubic hair');
-- Clothing color tags -- Clothing color tags
INSERT OR IGNORE INTO tags (name, category_id, description) VALUES INSERT OR IGNORE INTO tags (name, category_id, description) VALUES
('pink', (SELECT id FROM tag_categories WHERE name = 'clothing/color'), 'Pink clothing'), ('pink', (SELECT id FROM tag_categories WHERE name = 'clothing/color'), 'Pink clothing'),

View File

@ -208,3 +208,127 @@ func (s *TagStore) GetBySourceID(source, sourceID string) (*model.Tag, error) {
return &tag, nil return &tag, nil
} }
// FindOrCreate finds a tag by name and category, creating it if it doesn't exist
func (s *TagStore) FindOrCreate(tagName string, categoryID int64, source string) (*model.Tag, error) {
// Try to find existing tag
tag, err := s.GetByName(tagName)
if err == nil && tag != nil {
return tag, nil
}
// Create new tag if not found
if err := s.Create(&model.Tag{
Name: tagName,
CategoryID: categoryID,
Source: source,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}); err != nil {
return nil, fmt.Errorf("failed to create tag %s: %w", tagName, err)
}
createdTag := &model.Tag{
Name: tagName,
CategoryID: categoryID,
Source: source,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.Create(createdTag); err != nil {
return nil, fmt.Errorf("failed to create tag %s: %w", tagName, err)
}
// Try to get the newly created tag
return s.GetByName(tagName)
}
// Create new tag if not found
newTag := &model.Tag{
Name: tagName,
CategoryID: categoryID,
Source: source,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.Create(newTag); err != nil {
return nil, fmt.Errorf("failed to create tag %s: %w", tagName, err)
}
// Try to get the newly created tag
return s.GetByName(tagName)
}
// Create new tag if not found
newTag := &model.Tag{
Name: name,
CategoryID: categoryID,
Source: source,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.Create(newTag); err != nil {
return nil, fmt.Errorf("failed to create tag %s: %w", name, err)
}
// Try to get the newly created tag
return s.GetByName(name)
}
// FindOrCreate finds a tag by name and category, creating it if it doesn't exist
func (s *TagStore) FindOrCreate(tagName string, categoryID int64, source string) (*model.Tag, error) {
// Try to find existing tag
tag, err := s.GetByName(tagName)
if err == nil && tag != nil {
return tag, nil
}
// Create new tag if not found
err := s.Create(newTag)
if err != nil {
return nil, fmt.Errorf("failed to create tag %s: %w", tagName, err)
}
newTag := &model.Tag{
Name: tagName,
CategoryID: categoryID,
Source: source,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.Create(newTag); err != nil {
return nil, fmt.Errorf("failed to create tag %s: %w", tagName, err)
}
// Try to get the newly created tag
return s.GetByName(tagName)
}
// FindOrCreate finds a tag by name, creating it if it doesn't exist
func (s *TagStore) FindOrCreate(tagName, categoryID int64, source string) (*model.Tag, error) {
// Try to find existing tag
tag, err := s.GetByName(tagName)
if err == nil && tag != nil {
return tag, nil
}
// Create new tag if not found
newTag := &model.Tag{
Name: tagName,
CategoryID: categoryID,
Source: source,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.Create(newTag); err != nil {
return nil, fmt.Errorf("failed to create tag %s: %w", tagName, err)
}
// Try to get the newly created tag
return s.GetByName(tagName)
}

75
internal/import/enrich.go Normal file
View File

@ -0,0 +1,75 @@
package import_service
import (
"context"
"log"
"strings"
"time"
"git.leaktechnologies.dev/stu/Goondex/internal/db"
"git.leaktechnologies.dev/stu/Goondex/internal/model"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/adultemp"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/merger"
)
type Enricher struct {
db *db.DB
adult *adultemp.Scraper
delay time.Duration
enabled bool
}
func NewEnricher(database *db.DB, delay time.Duration) (*Enricher, error) {
adult, err := adultemp.NewScraper()
if err != nil {
return nil, err
}
return &Enricher{
db: database,
adult: adult,
delay: delay,
enabled: true,
}, nil
}
// EnrichPerformer tries to fill missing fields via Adult Empire by name search.
func (e *Enricher) EnrichPerformer(ctx context.Context, p *model.Performer) {
if !e.enabled || p == nil {
return
}
name := strings.TrimSpace(p.Name)
if name == "" {
return
}
results, err := e.adult.SearchPerformersByName(ctx, name)
if err != nil || len(results) == 0 {
return
}
data, err := e.adult.ScrapePerformerByURL(ctx, results[0].URL)
if err != nil {
return
}
// Only merge when names reasonably match
if !merger.ShouldMerge(p.Name, data.Name) {
return
}
merged := merger.MergePerformerData(p, data)
// Preserve original source IDs (TPDB format)
merged.Source = p.Source
merged.SourceID = p.SourceID
merged.SourceNumericID = p.SourceNumericID
merged.ID = p.ID
store := db.NewPerformerStore(e.db)
if err := store.Create(merged); err != nil {
log.Printf("enrich: failed to update performer %s: %v", name, err)
}
if e.delay > 0 {
time.Sleep(e.delay)
}
}

View File

@ -7,6 +7,7 @@ import (
"git.leaktechnologies.dev/stu/Goondex/internal/db" "git.leaktechnologies.dev/stu/Goondex/internal/db"
"git.leaktechnologies.dev/stu/Goondex/internal/model" "git.leaktechnologies.dev/stu/Goondex/internal/model"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/tpdb" "git.leaktechnologies.dev/stu/Goondex/internal/scraper/tpdb"
) )
@ -24,18 +25,145 @@ type ProgressCallback func(update ProgressUpdate)
// Service handles bulk import operations // Service handles bulk import operations
type Service struct { type Service struct {
db *db.DB db *db.DB
scraper *tpdb.Scraper scraper *tpdb.Scraper
bulkScraper scraper.BulkScraper
enricher *Enricher
} }
// NewService creates a new import service // NewService creates a new import service
func NewService(database *db.DB, scraper *tpdb.Scraper) *Service { func NewService(database *db.DB, scraper *tpdb.Scraper) *Service {
return &Service{ return &Service{
db: database, db: database,
scraper: scraper, scraper: scraper,
bulkScraper: nil,
enricher: nil,
} }
} }
// NewFlexibleService creates a new import service with Adult Empire scraper
func NewFlexibleService(database *db.DB, bulkScraper scraper.BulkScraper) *Service {
return &Service{
db: database,
scraper: nil,
bulkScraper: bulkScraper,
enricher: nil,
}
}
// WithEnricher configures enrichment (optional).
func (s *Service) WithEnricher(enricher *Enricher) {
s.enricher = enricher
}
// BulkImportAllPerformersFlexible imports all performers using Adult Empire scraper
func (s *Service) BulkImportAllPerformersFlexible(ctx context.Context) (*ImportResult, error) {
if s.bulkScraper == nil {
return s.BulkImportAllPerformers(ctx)
}
result := &ImportResult{
EntityType: "performers",
}
performerStore := db.NewPerformerStore(s.db)
// Get all performers from scraper
searchResults, err := s.bulkScraper.SearchAllPerformers(ctx)
if err != nil {
return result, fmt.Errorf("failed to fetch performers: %w", err)
}
result.Total = len(searchResults)
log.Printf("Found %d performer search results to import", len(searchResults))
// Import each performer
imported := 0
failed := 0
for _, searchResult := range searchResults {
// Convert to model
performer := s.bulkScraper.ConvertPerformerToModel(&searchResult)
if performer == nil {
failed++
continue
}
// Set source metadata
performer.Source = "adultempire"
performer.SourceID = searchResult.URL
// Try to create performer
if err := performerStore.Create(performer); err != nil {
log.Printf("Failed to import performer %s: %v", performer.Name, err)
failed++
} else {
imported++
log.Printf("Imported performer: %s", performer.Name)
}
}
result.Imported = imported
result.Failed = failed
log.Printf("Performers import complete: %d imported, %d failed", imported, failed)
return result, nil
}
// BulkImportAllScenesFlexible imports all scenes using Adult Empire scraper
func (s *Service) BulkImportAllScenesFlexible(ctx context.Context) (*ImportResult, error) {
if s.bulkScraper == nil {
return s.BulkImportAllScenes(ctx)
}
result := &ImportResult{
EntityType: "scenes",
}
sceneStore := db.NewSceneStore(s.db)
// Get all scenes from scraper
searchResults, err := s.bulkScraper.SearchAllScenes(ctx)
if err != nil {
return result, fmt.Errorf("failed to fetch scenes: %w", err)
}
result.Total = len(searchResults)
log.Printf("Found %d scene search results to import", len(searchResults))
// Import each scene
imported := 0
failed := 0
for _, searchResult := range searchResults {
// Convert to model
scene := s.bulkScraper.ConvertSceneToModel(&searchResult)
if scene == nil {
failed++
continue
}
// Set source metadata
scene.Source = "adultempire"
scene.SourceID = searchResult.URL
// Try to create scene
if err := sceneStore.Create(scene); err != nil {
log.Printf("Failed to import scene %s: %v", scene.Title, err)
failed++
} else {
imported++
log.Printf("Imported scene: %s", scene.Title)
}
}
result.Imported = imported
result.Failed = failed
log.Printf("Scenes import complete: %d imported, %d failed", imported, failed)
return result, nil
}
// ImportResult contains the results of an import operation // ImportResult contains the results of an import operation
type ImportResult struct { type ImportResult struct {
EntityType string EntityType string
@ -67,6 +195,15 @@ func (s *Service) BulkImportAllPerformersWithProgress(ctx context.Context, progr
// Update total on first page // Update total on first page
if meta != nil && page == 1 { if meta != nil && page == 1 {
result.Total = meta.Total result.Total = meta.Total
if meta.Total >= 10000 {
log.Printf("TPDB performers total reports %d (cap?). Continuing to paginate until empty.", meta.Total)
}
}
// Stop when no data is returned
if len(performers) == 0 {
log.Printf("No performers returned at page %d; stopping import.", page)
break
} }
// Import each performer // Import each performer
@ -76,6 +213,9 @@ func (s *Service) BulkImportAllPerformersWithProgress(ctx context.Context, progr
result.Failed++ result.Failed++
} else { } else {
result.Imported++ result.Imported++
if s.enricher != nil {
s.enricher.EnrichPerformer(ctx, &performer)
}
} }
// Send progress update // Send progress update
@ -92,11 +232,6 @@ func (s *Service) BulkImportAllPerformersWithProgress(ctx context.Context, progr
log.Printf("Imported page %d/%d of performers (%d/%d total)", page, meta.LastPage, result.Imported, result.Total) log.Printf("Imported page %d/%d of performers (%d/%d total)", page, meta.LastPage, result.Imported, result.Total)
// Check if we've reached the last page
if meta == nil || page >= meta.LastPage {
break
}
page++ page++
} }
@ -126,6 +261,14 @@ func (s *Service) BulkImportAllStudiosWithProgress(ctx context.Context, progress
// Update total on first page // Update total on first page
if meta != nil && page == 1 { if meta != nil && page == 1 {
result.Total = meta.Total result.Total = meta.Total
if meta.Total >= 10000 {
log.Printf("TPDB studios total reports %d (cap?). Continuing to paginate until empty.", meta.Total)
}
}
if len(studios) == 0 {
log.Printf("No studios returned at page %d; stopping import.", page)
break
} }
// Import each studio // Import each studio
@ -151,11 +294,6 @@ func (s *Service) BulkImportAllStudiosWithProgress(ctx context.Context, progress
log.Printf("Imported page %d/%d of studios (%d/%d total)", page, meta.LastPage, result.Imported, result.Total) log.Printf("Imported page %d/%d of studios (%d/%d total)", page, meta.LastPage, result.Imported, result.Total)
// Check if we've reached the last page
if meta == nil || page >= meta.LastPage {
break
}
page++ page++
} }
@ -188,6 +326,14 @@ func (s *Service) BulkImportAllScenesWithProgress(ctx context.Context, progress
// Update total on first page // Update total on first page
if meta != nil && page == 1 { if meta != nil && page == 1 {
result.Total = meta.Total result.Total = meta.Total
if meta.Total >= 10000 {
log.Printf("TPDB scenes total reports %d (cap?). Continuing to paginate until empty.", meta.Total)
}
}
if len(scenes) == 0 {
log.Printf("No scenes returned at page %d; stopping import.", page)
break
} }
// Import each scene with its performers and tags // Import each scene with its performers and tags
@ -269,11 +415,6 @@ func (s *Service) BulkImportAllScenesWithProgress(ctx context.Context, progress
log.Printf("Imported page %d/%d of scenes (%d/%d total)", page, meta.LastPage, result.Imported, result.Total) log.Printf("Imported page %d/%d of scenes (%d/%d total)", page, meta.LastPage, result.Imported, result.Total)
// Check if we've reached the last page
if meta == nil || page >= meta.LastPage {
break
}
page++ page++
} }

373
internal/ml/analysis.go Normal file
View File

@ -0,0 +1,373 @@
package ml
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"time"
"git.leaktechnologies.dev/stu/Goondex/internal/db"
"git.leaktechnologies.dev/stu/Goondex/internal/model"
)
// ScenePrediction represents ML prediction data for a scene
type ScenePrediction struct {
ID int64 `json:"id"`
PredictionType string `json:"prediction_type"`
Predictions map[string]float64 `json:"predictions"` // tag -> confidence
OverallScore float64 `json:"overall_score"`
Model string `json:"model"`
Confidence float64 `json:"confidence"`
CreatedAt interface{} `json:"created_at"`
UpdatedAt interface{} `json:"updated_at"`
}
// MLAnalysisService handles ML-powered scene analysis
type MLAnalysisService struct {
db *db.DB
}
// NewMLAnalysisService creates a new ML service
func NewMLAnalysisService(database *db.DB) *MLAnalysisService {
return &MLAnalysisService{
db: database,
}
}
// AnalyzeScene runs ML analysis on a scene and stores results
func (ml *MLAnalysisService) AnalyzeScene(ctx context.Context, sceneID int64, imageData []byte, modelVersion string) (*ScenePrediction, error) {
// For now, simulate ML analysis based on basic image processing
// In a real implementation, this would call your ML model
// Simulate detecting various attributes
predictions := make(map[string]float64)
// Detect hair-related attributes (based on your requirements)
predictions["shaved"] = ml.analyzeHairStyle(imageData)
predictions["natural_hair"] = ml.analyzeHairStyle(imageData)
predictions["bushy"] = ml.analyzeHairStyle(imageData)
// Detect gender attributes
predictions["male"] = ml.analyzeGender(imageData)
predictions["circumcised"] = ml.analyzeCircumcision(imageData)
// Detect body attributes
predictions["athletic"] = ml.analyzeBodyType(imageData, "athletic")
predictions["slim"] = ml.analyzeBodyType(imageData, "slim")
predictions["curvy"] = ml.analyzeBodyType(imageData, "curvy")
predictions["bbw"] = ml.analyzeBodyType(imageData, "bbw")
// Detect age categories
predictions["teen"] = ml.analyzeAgeCategory(imageData, "teen")
predictions["milf"] = ml.analyzeAgeCategory(imageData, "milf")
predictions["mature"] = ml.analyzeAgeCategory(imageData, "mature")
// Detect clothing
predictions["pink_clothing"] = ml.analyzeClothingColor(imageData, "pink")
predictions["black_clothing"] = ml.analyzeClothingColor(imageData, "black")
predictions["red_clothing"] = ml.analyzeClothingColor(imageData, "red")
predictions["blue_clothing"] = ml.analyzeClothingColor(imageData, "blue")
predictions["white_clothing"] = ml.analyzeClothingColor(imageData, "white")
predictions["thong"] = ml.analyzeClothingType(imageData, "thong")
predictions["panties"] = ml.analyzeClothingType(imageData, "panties")
predictions["lingerie"] = ml.analyzeClothingType(imageData, "lingerie")
predictions["dress"] = ml.analyzeClothingType(imageData, "dress")
predictions["skirt"] = ml.analyzeClothingType(imageData, "skirt")
predictions["heels"] = ml.analyzeClothingType(imageData, "heels")
predictions["boots"] = ml.analyzeClothingType(imageData, "boots")
predictions["stockings"] = ml.analyzeClothingType(imageData, "stockings")
// Detect actions/positions
predictions["creampie"] = ml.analyzeSexualAct(imageData, "creampie")
predictions["blowjob"] = ml.analyzeSexualAct(imageData, "blowjob")
predictions["cowgirl"] = ml.analyzePosition(imageData, "cowgirl")
predictions["doggy"] = ml.analyzePosition(imageData, "doggy")
// Detect settings
predictions["bedroom"] = ml.analyzeSetting(imageData, "bedroom")
predictions["couch"] = ml.analyzeSetting(imageData, "couch")
predictions["office"] = ml.analyzeSetting(imageData, "office")
predictions["kitchen"] = ml.analyzeSetting(imageData, "kitchen")
predictions["bathroom"] = ml.analyzeSetting(imageData, "bathroom")
predictions["car"] = ml.analyzeSetting(imageData, "car")
predictions["outdoor"] = ml.analyzeSetting(imageData, "outdoor")
// Detect objects/furniture
predictions["sofa"] = ml.analyzeObject(imageData, "sofa")
predictions["bed"] = ml.analyzeObject(imageData, "bed")
predictions["table"] = ml.analyzeObject(imageData, "table")
// Calculate overall confidence score
overallScore := ml.calculateOverallScore(predictions)
prediction := &ScenePrediction{
PredictionType: "comprehensive",
Predictions: predictions,
OverallScore: overallScore,
Model: modelVersion,
Confidence: overallScore,
}
// Store analysis results
if err := ml.storeSceneAnalysis(ctx, sceneID, prediction); err != nil {
return nil, fmt.Errorf("failed to store scene analysis: %w", err)
}
log.Printf("ML analysis complete for scene %d: overall score %.2f, %d predictions",
sceneID, overallScore, len(predictions))
return prediction, nil
}
// GetSceneAnalysis retrieves stored ML analysis for a scene
func (ml *MLAnalysisService) GetSceneAnalysis(ctx context.Context, sceneID int64) ([]ScenePrediction, error) {
rows, err := ml.db.Conn().Query(`
SELECT id, model_version, prediction_type, predictions, confidence_score, created_at, updated_at
FROM scene_ml_analysis
WHERE scene_id = ?
ORDER BY created_at DESC
`, sceneID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve scene analysis: %w", err)
}
defer rows.Close()
var predictions []ScenePrediction
for rows.Next() {
var prediction ScenePrediction
var predictionsJSON string
var createdAt, updatedAt string
err := rows.Scan(
&prediction.ID, &prediction.Model, &prediction.PredictionType,
&predictionsJSON, &prediction.OverallScore, &prediction.Confidence,
&createdAt, &updatedAt,
)
if err != nil {
continue
}
// Parse predictions JSON
if err := json.Unmarshal([]byte(predictionsJSON), &prediction.Predictions); err != nil {
continue
}
// Parse timestamps (for now, store as strings)
prediction.CreatedAt = parseTime(createdAt)
prediction.UpdatedAt = parseTime(updatedAt)
predictions = append(predictions, prediction)
}
return predictions, nil
}
// UpdateSceneTags applies ML predictions to scene_tags table
func (ml *MLAnalysisService) UpdateSceneTags(ctx context.Context, sceneID int64, minConfidence float64) error {
predictions, err := ml.GetSceneAnalysis(ctx, sceneID)
if err != nil {
return fmt.Errorf("failed to get scene analysis: %w", err)
}
if len(predictions) == 0 {
return nil
}
// Get the latest high-confidence predictions
latest := predictions[0]
for _, prediction := range predictions {
if prediction.Confidence > latest.Confidence {
latest = prediction
}
}
// Apply predictions to scene_tags table
tagStore := db.NewTagStore(ml.db)
for tagName, confidence := range latest.Predictions {
if confidence < minConfidence {
continue // Skip low-confidence predictions
}
// Find or create the tag
tag, err := tagStore.FindOrCreate(tagName, "ml")
if err != nil {
log.Printf("Failed to find/create tag %s: %v", tagName, err)
continue
}
// Link tag to scene with ML source and confidence
if err := ml.linkSceneToTag(ctx, sceneID, tag.ID, confidence, "ml"); err != nil {
log.Printf("Failed to link scene %d to tag %d: %v", sceneID, tag.ID, err)
}
}
log.Printf("Applied %d ML predictions to scene %d", len(latest.Predictions), sceneID)
return nil
}
// Mock ML analysis functions (replace with real ML model calls)
func (ml *MLAnalysisService) analyzeHairStyle(imageData []byte) float64 {
// Simulate hair style analysis
return 0.7 // Mock confidence
}
func (ml *MLAnalysisService) analyzeGender(imageData []byte) float64 {
// Simulate gender analysis
return 0.8 // Mock confidence
}
func (ml *MLAnalysisService) analyzeCircumcision(imageData []byte) float64 {
// Simulate circumcision detection
return 0.6 // Mock confidence
}
func (ml *MLAnalysisService) analyzeBodyType(imageData []byte, bodyType string) float64 {
// Simulate body type analysis
switch bodyType {
case "athletic", "slim":
return 0.8
case "curvy":
return 0.7
case "bbw":
return 0.9
default:
return 0.5
}
}
func (ml *MLAnalysisService) analyzeAgeCategory(imageData []byte, ageCat string) float64 {
// Simulate age category analysis
switch ageCat {
case "teen", "milf", "mature":
return 0.9
default:
return 0.5
}
}
func (ml *MLAnalysisService) analyzeClothingColor(imageData []byte, color string) float64 {
// Simulate clothing color detection
switch color {
case "pink", "black", "red", "blue":
return 0.9
default:
return 0.5
}
}
func (ml *MLAnalysisService) analyzeClothingType(imageData []byte, clothingType string) float64 {
// Simulate clothing type detection
switch clothingType {
case "thong", "heels":
return 0.85
case "stockings", "lingerie":
return 0.75
default:
return 0.5
}
}
func (ml *MLAnalysisService) analyzeSexualAct(imageData []byte, act string) float64 {
// Simulate sexual act detection
switch act {
case "creampie", "blowjob", "cowgirl", "doggy":
return 0.9
default:
return 0.5
}
}
func (ml *MLAnalysisService) analyzePosition(imageData []byte, position string) float64 {
// Simulate position detection
switch position {
case "cowgirl", "doggy":
return 0.85
default:
return 0.5
}
}
func (ml *MLAnalysisService) analyzeSetting(imageData []byte, setting string) float64 {
// Simulate setting detection
switch setting {
case "bedroom", "couch":
return 0.8
case "office":
return 0.6
case "kitchen":
return 0.6
case "bathroom":
return 0.6
case "car":
return 0.7
case "outdoor":
return 0.7
default:
return 0.5
}
}
func (ml *MLAnalysisService) analyzeObject(imageData []byte, objectType string) float64 {
// Simulate object detection
switch objectType {
case "sofa":
return 0.8
case "bed", "table":
return 0.9
default:
return 0.5
}
}
func (ml *MLAnalysisService) calculateOverallScore(predictions map[string]float64) float64 {
if len(predictions) == 0 {
return 0.0
}
total := 0.0
count := 0
for _, confidence := range predictions {
total += confidence
count++
}
// Weighted average with bonus for having multiple predictions
average := total / float64(count)
multiplier := 1.0 + (float64(count)-1.0)*0.1 // Bonus for comprehensive coverage
return average * multiplier
}
func (ml *MLAnalysisService) storeSceneAnalysis(ctx context.Context, sceneID int64, prediction *ScenePrediction) error {
predictionsJSON, err := json.Marshal(prediction.Predictions)
if err != nil {
return fmt.Errorf("failed to marshal predictions: %w", err)
}
_, err = ml.db.Conn().Exec(`
INSERT INTO scene_ml_analysis (scene_id, model_version, prediction_type, predictions, confidence_score, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`, sceneID, prediction.Model, prediction.PredictionType, predictionsJSON, prediction.OverallScore)
return err
}
func (ml *MLAnalysisService) linkSceneToTag(ctx context.Context, sceneID, tagID int64, confidence float64, source string) error {
_, err := ml.db.Conn().Exec(`
INSERT OR REPLACE INTO scene_tags (scene_id, tag_id, confidence, source, verified, created_at)
VALUES (?, ?, ?, ?, ?, 0, datetime('now'))
`, sceneID, tagID, confidence, source)
return err
}
func parseTime(timeStr string) interface{} {
// For now, return as string. In real implementation, parse to time.Time
return timeStr
}

117
internal/scraper/bulk.go Normal file
View File

@ -0,0 +1,117 @@
package scraper
import (
"context"
"git.leaktechnologies.dev/stu/Goondex/internal/model"
adultemp "git.leaktechnologies.dev/stu/Goondex/internal/scraper/adultemp"
)
// BulkScraper interface defines bulk import capabilities
type BulkScraper interface {
SearchAllPerformers(ctx context.Context) ([]adultemp.SearchResult, error)
SearchAllStudios(ctx context.Context) ([]adultemp.SearchResult, error)
SearchAllScenes(ctx context.Context) ([]adultemp.SearchResult, error)
ConvertPerformerToModel(data interface{}) *model.Performer
ConvertStudioToModel(data interface{}) *model.Studio
ConvertSceneToModel(data interface{}) *model.Scene
}
// AdultEmpireBulkScraper implements bulk operations using individual searches
type AdultEmpireBulkScraper struct {
scraper *adultemp.Scraper
}
// NewAdultEmpireBulkScraper creates a bulk scraper for Adult Empire
func NewAdultEmpireBulkScraper() (*AdultEmpireBulkScraper, error) {
scraper, err := adultemp.NewScraper()
if err != nil {
return nil, err
}
return &AdultEmpireBulkScraper{
scraper: scraper,
}, nil
}
// SearchAllPerformers fetches all performers by using generic searches
func (a *AdultEmpireBulkScraper) SearchAllPerformers(ctx context.Context) ([]adultemp.SearchResult, error) {
searchTerms := []string{"", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"}
var allResults []adultemp.SearchResult
seen := make(map[string]bool)
for _, term := range searchTerms {
if len(allResults) >= 1000 {
break
}
results, err := a.scraper.SearchPerformersByName(ctx, term)
if err != nil {
continue
}
for _, result := range results {
if !seen[result.URL] {
seen[result.URL] = true
allResults = append(allResults, result)
}
}
}
return allResults, nil
}
// SearchAllStudios fetches all studios (not fully supported by Adult Empire)
func (a *AdultEmpireBulkScraper) SearchAllStudios(ctx context.Context) ([]adultemp.SearchResult, error) {
// Adult Empire doesn't have dedicated studio search, return empty for now
return []adultemp.SearchResult{}, nil
}
// SearchAllScenes fetches all scenes
func (a *AdultEmpireBulkScraper) SearchAllScenes(ctx context.Context) ([]adultemp.SearchResult, error) {
searchTerms := []string{"", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"}
var allResults []adultemp.SearchResult
seen := make(map[string]bool)
for _, term := range searchTerms {
if len(allResults) >= 2000 {
break
}
results, err := a.scraper.SearchScenesByName(ctx, term)
if err != nil {
continue
}
for _, result := range results {
if !seen[result.URL] {
seen[result.URL] = true
allResults = append(allResults, result)
}
}
}
return allResults, nil
}
// ConvertPerformerToModel converts Adult Empire performer data
func (a *AdultEmpireBulkScraper) ConvertPerformerToModel(data interface{}) *model.Performer {
if performerData, ok := data.(*adultemp.PerformerData); ok {
return a.scraper.ConvertPerformerToModel(performerData)
}
return nil
}
// ConvertStudioToModel converts studio data (not implemented for Adult Empire)
func (a *AdultEmpireBulkScraper) ConvertStudioToModel(data interface{}) *model.Studio {
return nil
}
// ConvertSceneToModel converts scene data
func (a *AdultEmpireBulkScraper) ConvertSceneToModel(data interface{}) *model.Scene {
if sceneData, ok := data.(*adultemp.SceneData); ok {
return a.scraper.ConvertSceneToModel(sceneData)
}
return nil
}

439
internal/search/advanced.go Normal file
View File

@ -0,0 +1,439 @@
package search
import (
"database/sql"
"fmt"
"math"
"strings"
"time"
"git.leaktechnologies.dev/stu/Goondex/internal/db"
"git.leaktechnologies.dev/stu/Goondex/internal/model"
)
// AdvancedSearch handles complex scene search with ML tag matching
type AdvancedSearch struct {
db *db.DB
parser *Parser
sceneStore *db.SceneStore
performerStore *db.PerformerStore
tagStore *db.TagStore
}
// SearchResult represents a scored search result
type SearchResult struct {
Scene model.Scene `json:"scene"`
Score float64 `json:"score"`
MatchInfo MatchInfo `json:"match_info"`
Related []model.Scene `json:"related,omitempty"`
}
// MatchInfo details what matched in the search
type MatchInfo struct {
PerformerMatch []string `json:"performer_match"`
TagMatches []string `json:"tag_matches"`
Confidence float64 `json:"confidence"`
}
// NewAdvancedSearch creates a new advanced search service
func NewAdvancedSearch(database *db.DB) *AdvancedSearch {
return &AdvancedSearch{
db: database,
parser: NewParser(),
sceneStore: db.NewSceneStore(database),
performerStore: db.NewPerformerStore(database),
tagStore: db.NewTagStore(database),
}
}
// Search performs advanced search with natural language parsing
func (as *AdvancedSearch) Search(query string, limit int) ([]SearchResult, error) {
// Parse the natural language query
parsedQuery := as.parser.Parse(query)
// If no specific criteria, fallback to basic title search
if as.isSimpleQuery(parsedQuery) {
return as.basicSearch(query, limit)
}
// Perform advanced tag-based search
return as.advancedSearch(parsedQuery, limit)
}
// isSimpleQuery checks if query has specific searchable criteria
func (as *AdvancedSearch) isSimpleQuery(q *SearchQuery) bool {
return len(q.Performers) == 0 && len(q.Actions) == 0 &&
len(q.Clothing) == 0 && len(q.Colors) == 0 &&
len(q.AgeCategories) == 0 && len(q.Settings) == 0
}
// basicSearch performs simple title-based search
func (as *AdvancedSearch) basicSearch(query string, limit int) ([]SearchResult, error) {
scenes, err := as.sceneStore.Search(query)
if err != nil {
return nil, err
}
results := make([]SearchResult, len(scenes))
for i, scene := range scenes {
results[i] = SearchResult{
Scene: scene,
Score: as.calculateTitleScore(scene.Title, query),
MatchInfo: MatchInfo{
Confidence: 0.5,
},
}
}
return results, nil
}
// advancedSearch performs complex tag-based search
func (as *AdvancedSearch) advancedSearch(q *SearchQuery, limit int) ([]SearchResult, error) {
var results []SearchResult
// Search by performer names first
if len(q.Performers) > 0 {
performerResults, err := as.searchByPerformers(q.Performers, limit)
if err != nil {
return nil, err
}
results = append(results, performerResults...)
}
// Search by tags (actions, clothing, colors, etc.)
tagResults, err := as.searchByTags(q, limit)
if err != nil {
return nil, err
}
results = append(results, tagResults...)
// Remove duplicates and sort by score
results = as.deduplicateAndSort(results, limit)
// Add related content if requested
if len(results) > 0 {
results = as.addRelatedContent(results)
}
return results, nil
}
// searchByPerformers finds scenes with specific performers
func (as *AdvancedSearch) searchByPerformers(performerNames []string, limit int) ([]SearchResult, error) {
var results []SearchResult
for _, name := range performerNames {
performers, err := as.performerStore.Search(name)
if err != nil {
continue
}
for _, performer := range performers {
scenes, err := as.getScenesByPerformer(performer.ID)
if err != nil {
continue
}
for _, scene := range scenes {
score := 1.0 // Perfect match for performer
if !strings.Contains(strings.ToLower(scene.Title), strings.ToLower(name)) {
score = 0.8 // Scene exists but name not in title
}
results = append(results, SearchResult{
Scene: scene,
Score: score,
MatchInfo: MatchInfo{
PerformerMatch: []string{name},
Confidence: score,
},
})
}
}
}
return results, nil
}
// searchByTags finds scenes matching various tag categories
func (as *AdvancedSearch) searchByTags(q *SearchQuery, limit int) ([]SearchResult, error) {
// Build complex SQL query for tag matching
whereClauses := []string{}
args := []interface{}{}
// Add clothing color tags
for _, color := range q.Colors {
whereClauses = append(whereClauses, "t.name LIKE ?")
args = append(args, "%"+color+"%")
}
// Add clothing type tags
for _, clothing := range q.Clothing {
whereClauses = append(whereClauses, "t.name LIKE ?")
args = append(args, "%"+clothing+"%")
}
// Add action tags
for _, action := range q.Actions {
whereClauses = append(whereClauses, "t.name LIKE ?")
args = append(args, "%"+action+"%")
}
// Add age category tags
for _, age := range q.AgeCategories {
whereClauses = append(whereClauses, "t.name LIKE ?")
args = append(args, "%"+age+"%")
}
// Add setting tags
for _, setting := range q.Settings {
whereClauses = append(whereClauses, "t.name LIKE ?")
args = append(args, "%"+setting+"%")
}
if len(whereClauses) == 0 {
return []SearchResult{}, nil
}
// Execute complex tag search query
query := `
SELECT DISTINCT s.*, COUNT(st.tag_id) as match_count, AVG(st.confidence) as avg_confidence
FROM scenes s
INNER JOIN scene_tags st ON s.id = st.scene_id
INNER JOIN tags t ON st.tag_id = t.id
WHERE ` + strings.Join(whereClauses, " OR ") + `
GROUP BY s.id
ORDER BY match_count DESC, avg_confidence DESC
LIMIT ?
`
args = append(args, limit*2) // Get more for deduplication
rows, err := as.db.Conn().Query(query, args...)
if err != nil {
return nil, fmt.Errorf("tag search failed: %w", err)
}
defer rows.Close()
return as.scanSearchResults(rows), nil
}
// getScenesByPerformer retrieves scenes for a specific performer
func (as *AdvancedSearch) getScenesByPerformer(performerID int64) ([]model.Scene, error) {
rows, err := as.db.Conn().Query(`
SELECT s.id, s.title, COALESCE(s.code, ''), COALESCE(s.date, ''),
COALESCE(s.studio_id, 0), COALESCE(s.description, ''),
COALESCE(s.image_path, ''), COALESCE(s.image_url, ''),
COALESCE(s.director, ''), COALESCE(s.url, ''),
COALESCE(s.source, ''), COALESCE(s.source_id, ''),
s.created_at, s.updated_at
FROM scenes s
INNER JOIN scene_performers sp ON s.id = sp.scene_id
WHERE sp.performer_id = ?
ORDER BY s.date DESC, s.title
`, performerID)
if err != nil {
return nil, err
}
defer rows.Close()
return as.scanScenes(rows)
}
// calculateTitleScore calculates relevance score for title matching
func (as *AdvancedSearch) calculateTitleScore(title, query string) float64 {
title = strings.ToLower(title)
query = strings.ToLower(query)
// Exact match
if title == query {
return 1.0
}
// Title contains query
if strings.Contains(title, query) {
return 0.8
}
// Query contains title
if strings.Contains(query, title) {
return 0.6
}
// Word overlap
titleWords := strings.Fields(title)
queryWords := strings.Fields(query)
matches := 0
for _, qWord := range queryWords {
for _, tWord := range titleWords {
if qWord == tWord {
matches++
break
}
}
}
if len(queryWords) == 0 {
return 0.0
}
return float64(matches) / float64(len(queryWords)) * 0.4
}
// deduplicateAndSort removes duplicate scenes and sorts by score
func (as *AdvancedSearch) deduplicateAndSort(results []SearchResult, limit int) []SearchResult {
seen := make(map[int64]bool)
unique := []SearchResult{}
for _, result := range results {
if !seen[result.Scene.ID] {
seen[result.Scene.ID] = true
unique = append(unique, result)
}
}
// Sort by score (higher first)
for i := 0; i < len(unique); i++ {
for j := i + 1; j < len(unique); j++ {
if unique[j].Score > unique[i].Score {
unique[i], unique[j] = unique[j], unique[i]
}
}
}
if len(unique) > limit {
unique = unique[:limit]
}
return unique
}
// addRelatedContent adds related scenes to search results
func (as *AdvancedSearch) addRelatedContent(results []SearchResult) []SearchResult {
if len(results) == 0 {
return results
}
// For now, add scenes from same studio or performers
baseScene := results[0].Scene
related, err := as.findRelatedScenes(baseScene.ID, *baseScene.StudioID)
if err != nil {
return results
}
if len(related) > 3 {
related = related[:3] // Limit related content
}
results[0].Related = related
return results
}
// findRelatedScenes finds scenes related to a base scene
func (as *AdvancedSearch) findRelatedScenes(sceneID, studioID int64) ([]model.Scene, error) {
// Find scenes with same studio or same performers
query := `
SELECT DISTINCT s.id, s.title, COALESCE(s.code, ''), COALESCE(s.date, ''),
COALESCE(s.studio_id, 0), COALESCE(s.description, ''),
COALESCE(s.image_path, ''), COALESCE(s.image_url, ''),
COALESCE(s.director, ''), COALESCE(s.url, ''),
COALESCE(s.source, ''), COALESCE(s.source_id, ''),
s.created_at, s.updated_at
FROM scenes s
WHERE (s.studio_id = ? OR s.id IN (
SELECT sp2.scene_id
FROM scene_performers sp1
INNER JOIN scene_performers sp2 ON sp1.performer_id = sp2.performer_id
WHERE sp1.scene_id = ? AND sp2.scene_id != ?
)) AND s.id != ?
ORDER BY s.date DESC
LIMIT 10
`
rows, err := as.db.Conn().Query(query, studioID, sceneID, sceneID, sceneID)
if err != nil {
return nil, err
}
defer rows.Close()
return as.scanScenes(rows)
}
// scanSearchResults converts SQL rows to SearchResult structs
func (as *AdvancedSearch) scanSearchResults(rows *sql.Rows) []SearchResult {
var results []SearchResult
for rows.Next() {
var scene model.Scene
var createdAt, updatedAt string
var matchCount int
var avgConfidence float64
err := rows.Scan(
&scene.ID, &scene.Title, &scene.Code, &scene.Date, &scene.StudioID,
&scene.Description, &scene.ImagePath, &scene.ImageURL, &scene.Director,
&scene.URL, &scene.Source, &scene.SourceID, &createdAt, &updatedAt,
&matchCount, &avgConfidence,
)
if err != nil {
continue
}
// Parse timestamps
if parsedTime, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil {
scene.CreatedAt = parsedTime
}
if parsedTime, err := time.Parse("2006-01-02 15:04:05", updatedAt); err == nil {
scene.UpdatedAt = parsedTime
}
// Calculate composite score
score := math.Min(avgConfidence*0.7+float64(matchCount)*0.3, 1.0)
results = append(results, SearchResult{
Scene: scene,
Score: score,
MatchInfo: MatchInfo{
Confidence: avgConfidence,
},
})
}
return results
}
// scanScenes converts SQL rows to Scene structs
func (as *AdvancedSearch) scanScenes(rows *sql.Rows) ([]model.Scene, error) {
var scenes []model.Scene
for rows.Next() {
var scene model.Scene
var createdAt, updatedAt string
err := rows.Scan(
&scene.ID, &scene.Title, &scene.Code, &scene.Date, &scene.StudioID,
&scene.Description, &scene.ImagePath, &scene.ImageURL, &scene.Director,
&scene.URL, &scene.Source, &scene.SourceID, &createdAt, &updatedAt,
)
if err != nil {
continue
}
// Parse timestamps
if parsedTime, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil {
scene.CreatedAt = parsedTime
}
if parsedTime, err := time.Parse("2006-01-02 15:04:05", updatedAt); err == nil {
scene.UpdatedAt = parsedTime
}
scenes = append(scenes, scene)
}
return scenes, nil
}

200
internal/search/parser.go Normal file
View File

@ -0,0 +1,200 @@
package search
import (
"regexp"
"strings"
)
// SearchQuery represents a parsed search query
type SearchQuery struct {
Original string
Performers []string
Actions []string
Clothing []string
Colors []string
BodyTypes []string
AgeCategories []string
Ethnicities []string
Settings []string
Positions []string
Production []string
Requirements []string // must-have terms
Preferences []string // nice-to-have terms
}
// Parser handles natural language search query parsing
type Parser struct {
// Keyword mappings for different categories
actions map[string]bool
clothing map[string]bool
colors map[string]bool
bodyTypes map[string]bool
ageCategories map[string]bool
ethnicities map[string]bool
settings map[string]bool
positions map[string]bool
production map[string]bool
}
// NewParser creates a new search query parser
func NewParser() *Parser {
p := &Parser{
actions: make(map[string]bool),
clothing: make(map[string]bool),
colors: make(map[string]bool),
bodyTypes: make(map[string]bool),
ageCategories: make(map[string]bool),
ethnicities: make(map[string]bool),
settings: make(map[string]bool),
positions: make(map[string]bool),
production: make(map[string]bool),
}
// Initialize keyword mappings
p.initializeKeywords()
return p
}
// Parse parses a natural language search query
func (p *Parser) Parse(query string) *SearchQuery {
query = strings.ToLower(query)
query = strings.TrimSpace(query)
sq := &SearchQuery{
Original: query,
Performers: []string{},
Actions: []string{},
Clothing: []string{},
Colors: []string{},
BodyTypes: []string{},
AgeCategories: []string{},
Ethnicities: []string{},
Settings: []string{},
Positions: []string{},
Production: []string{},
Requirements: []string{},
Preferences: []string{},
}
// Extract performer names (proper nouns, capitalized terms)
performerRegex := regexp.MustCompile(`\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b`)
matches := performerRegex.FindAllString(query, -1)
for _, match := range matches {
if len(match) > 2 { // Only consider names longer than 2 chars
sq.Performers = append(sq.Performers, match)
}
}
// Extract age-specific terms
if strings.Contains(query, "teen") || strings.Contains(query, "teenage") {
sq.AgeCategories = append(sq.AgeCategories, "teen")
}
if strings.Contains(query, "milf") {
sq.AgeCategories = append(sq.AgeCategories, "milf")
}
if strings.Contains(query, "mature") {
sq.AgeCategories = append(sq.AgeCategories, "mature")
}
// Extract sexual acts
sexualActs := []string{"creampie", "anal", "blowjob", "cumshot", "facial", "threesome", "gangbang"}
for _, act := range sexualActs {
if strings.Contains(query, act) {
sq.Actions = append(sq.Actions, act)
}
}
// Extract clothing items
clothingItems := []string{"thong", "panties", "bra", "lingerie", "heels", "stockings", "dress", "skirt"}
for _, item := range clothingItems {
if strings.Contains(query, item) {
sq.Clothing = append(sq.Clothing, item)
}
}
// Extract colors
colors := []string{"pink", "black", "red", "blue", "white", "yellow", "green", "purple"}
for _, color := range colors {
if strings.Contains(query, color) {
sq.Colors = append(sq.Colors, color)
}
}
// Extract body types
bodyTypes := []string{"big tit", "large breast", "slim", "curvy", "athletic", "bbw"}
for _, bodyType := range bodyTypes {
if strings.Contains(query, bodyType) {
sq.BodyTypes = append(sq.BodyTypes, bodyType)
}
}
// Extract settings
settings := []string{"couch", "bed", "bedroom", "office", "outdoor", "car", "shower"}
for _, setting := range settings {
if strings.Contains(query, setting) {
sq.Settings = append(sq.Settings, setting)
}
}
// All remaining terms become preferences/requirements
words := strings.Fields(query)
for _, word := range words {
if len(word) > 2 && !p.isCategorized(word, sq) {
// Check if it's preceded by "with" or similar requirement indicators
if strings.Contains(query, "with "+word) || strings.Contains(query, "has "+word) {
sq.Requirements = append(sq.Requirements, word)
} else {
sq.Preferences = append(sq.Preferences, word)
}
}
}
return sq
}
// initializeKeywords sets up the keyword mappings
func (p *Parser) initializeKeywords() {
// Sexual actions
for _, act := range []string{"creampie", "anal", "blowjob", "cumshot", "facial"} {
p.actions[act] = true
}
// Clothing
for _, item := range []string{"thong", "panties", "lingerie", "heels"} {
p.clothing[item] = true
}
// Colors
for _, color := range []string{"pink", "black", "red", "blue", "white"} {
p.colors[color] = true
}
// Body types
for _, bodyType := range []string{"big tit", "slim", "curvy"} {
p.bodyTypes[bodyType] = true
}
// Age categories
for _, age := range []string{"teen", "milf", "mature"} {
p.ageCategories[age] = true
}
// Settings
for _, setting := range []string{"couch", "bedroom", "office"} {
p.settings[setting] = true
}
}
// isCategorized checks if a word has already been categorized
func (p *Parser) isCategorized(word string, sq *SearchQuery) bool {
word = strings.ToLower(word)
for _, performer := range sq.Performers {
if strings.Contains(strings.ToLower(performer), word) {
return true
}
}
return p.actions[word] || p.clothing[word] || p.colors[word] ||
p.bodyTypes[word] || p.ageCategories[word] || p.settings[word]
}

View File

@ -9,16 +9,17 @@ import (
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"git.leaktechnologies.dev/stu/Goondex/internal/config"
"git.leaktechnologies.dev/stu/Goondex/internal/db" "git.leaktechnologies.dev/stu/Goondex/internal/db"
import_service "git.leaktechnologies.dev/stu/Goondex/internal/import" import_service "git.leaktechnologies.dev/stu/Goondex/internal/import"
"git.leaktechnologies.dev/stu/Goondex/internal/model" "git.leaktechnologies.dev/stu/Goondex/internal/model"
"git.leaktechnologies.dev/stu/Goondex/internal/config" "git.leaktechnologies.dev/stu/Goondex/internal/scraper"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/adultemp" "git.leaktechnologies.dev/stu/Goondex/internal/search"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/tpdb"
"git.leaktechnologies.dev/stu/Goondex/internal/sync" "git.leaktechnologies.dev/stu/Goondex/internal/sync"
) )
@ -33,9 +34,10 @@ type Server struct {
db *db.DB db *db.DB
templates *template.Template templates *template.Template
addr string addr string
dbPath string
} }
func NewServer(database *db.DB, addr string) (*Server, error) { func NewServer(database *db.DB, addr string, dbPath string) (*Server, error) {
tmpl, err := template.ParseFS(content, "templates/*.html") tmpl, err := template.ParseFS(content, "templates/*.html")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse templates: %w", err) return nil, fmt.Errorf("failed to parse templates: %w", err)
@ -45,6 +47,7 @@ func NewServer(database *db.DB, addr string) (*Server, error) {
db: database, db: database,
templates: tmpl, templates: tmpl,
addr: addr, addr: addr,
dbPath: dbPath,
}, nil }, nil
} }
@ -91,6 +94,7 @@ func (s *Server) Start() error {
// Settings endpoints // Settings endpoints
mux.HandleFunc("/api/settings/api-keys", s.handleAPISettingsKeys) mux.HandleFunc("/api/settings/api-keys", s.handleAPISettingsKeys)
mux.HandleFunc("/api/settings/database", s.handleAPIDatabase)
// API // API
mux.HandleFunc("/api/import/performer", s.handleAPIImportPerformer) mux.HandleFunc("/api/import/performer", s.handleAPIImportPerformer)
@ -1109,25 +1113,30 @@ func (s *Server) handleAPIBulkImportPerformers(w http.ResponseWriter, r *http.Re
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
apiKey, err := tpdbAPIKey() // Try Adult Empire first (primary scraper for new imports)
if writeTPDBError(w, err) { bulkScraper, err := scraper.NewAdultEmpireBulkScraper()
return
}
scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey)
service := import_service.NewService(s.db, scraper)
result, err := service.BulkImportAllPerformers(context.Background())
if err != nil { if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Import failed: %v", err)}) // Fall back to TPDB if Adult Empire fails
apiKey, keyErr := tpdbAPIKey()
if writeTPDBError(w, keyErr) {
return
}
tpdbScraper := tpdb.NewScraper("https://api.theporndb.net", apiKey)
service := import_service.NewService(s.db, tpdbScraper)
if enricher, enrichErr := import_service.NewEnricher(s.db, 1*time.Second); enrichErr == nil {
service.WithEnricher(enricher)
}
result, err := service.BulkImportAllPerformers(context.Background())
s.writeImportResult(w, result, err, "Performers")
return return
} }
json.NewEncoder(w).Encode(APIResponse{ // Use Adult Empire scraper
Success: true, service := import_service.NewFlexibleService(s.db, bulkScraper)
Message: fmt.Sprintf("Imported %d/%d performers", result.Imported, result.Total), result, err := service.BulkImportAllPerformersFlexible(context.Background())
Data: result, s.writeImportResult(w, result, err, "Performers")
})
} }
func (s *Server) handleAPIBulkImportStudios(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAPIBulkImportStudios(w http.ResponseWriter, r *http.Request) {
@ -1322,6 +1331,11 @@ func (s *Server) handleAPIBulkImportScenesProgress(w http.ResponseWriter, r *htt
// ============================================================================ // ============================================================================
func (s *Server) handleAPIGlobalSearch(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAPIGlobalSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q") query := r.URL.Query().Get("q")
if query == "" { if query == "" {
json.NewEncoder(w).Encode(APIResponse{ json.NewEncoder(w).Encode(APIResponse{
@ -1331,29 +1345,36 @@ func (s *Server) handleAPIGlobalSearch(w http.ResponseWriter, r *http.Request) {
return return
} }
performerStore := db.NewPerformerStore(s.db) // Use advanced search for complex queries
studioStore := db.NewStudioStore(s.db) advancedSearch := search.NewAdvancedSearch(s.db)
sceneStore := db.NewSceneStore(s.db) results, err := advancedSearch.Search(query, 20)
tagStore := db.NewTagStore(s.db) if err != nil {
json.NewEncoder(w).Encode(APIResponse{
Success: false,
Message: fmt.Sprintf("Search failed: %v", err),
})
return
}
performers, _ := performerStore.Search(query) // Convert to format expected by frontend
studios, _ := studioStore.Search(query) scenes := make([]model.Scene, len(results))
scenes, _ := sceneStore.Search(query) for i, result := range results {
tags, _ := tagStore.Search(query) scenes[i] = result.Scene
}
results := map[string]interface{}{ response := map[string]interface{}{
"performers": performers, "scenes": scenes,
"studios": studios, "total": len(results),
"scenes": scenes, "advanced": true,
"tags": tags, "search_query": query,
"total": len(performers) + len(studios) + len(scenes) + len(tags),
} }
json.NewEncoder(w).Encode(APIResponse{ json.NewEncoder(w).Encode(APIResponse{
Success: true, Success: true,
Message: fmt.Sprintf("Found %d results", results["total"]), Message: fmt.Sprintf("Found %d advanced results", len(results)),
Data: results, Data: response,
}) })
} }
// ============================================================================ // ============================================================================
@ -1369,6 +1390,7 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{ data := map[string]interface{}{
"PageTitle": "Settings", "PageTitle": "Settings",
"ActivePage": "settings", "ActivePage": "settings",
"DBPath": s.dbPath,
} }
s.render(w, "settings.html", data) s.render(w, "settings.html", data)
@ -1386,14 +1408,14 @@ func (s *Server) handleAPISettingsKeys(w http.ResponseWriter, r *http.Request) {
case http.MethodGet: case http.MethodGet:
keys := config.GetAPIKeys() keys := config.GetAPIKeys()
resp := map[string]interface{}{ resp := map[string]interface{}{
"tpdbConfigured": keys.TPDBAPIKey != "", "tpdbConfigured": keys.TPDBAPIKey != "",
"aeConfigured": keys.AEAPIKey != "", "aeConfigured": keys.AEAPIKey != "",
"stashdbConfigured": keys.StashDBAPIKey != "", "stashdbConfigured": keys.StashDBAPIKey != "",
"stashdbEndpoint": keys.StashDBEndpoint, "stashdbEndpoint": keys.StashDBEndpoint,
"tpdb_api_key": keys.TPDBAPIKey, // local-only UI; if you prefer, mask these "tpdb_api_key": keys.TPDBAPIKey, // local-only UI; if you prefer, mask these
"ae_api_key": keys.AEAPIKey, "ae_api_key": keys.AEAPIKey,
"stashdb_api_key": keys.StashDBAPIKey, "stashdb_api_key": keys.StashDBAPIKey,
"stashdb_endpoint": keys.StashDBEndpoint, // duplicate for UI convenience "stashdb_endpoint": keys.StashDBEndpoint, // duplicate for UI convenience
} }
json.NewEncoder(w).Encode(APIResponse{ json.NewEncoder(w).Encode(APIResponse{
Success: true, Success: true,
@ -1427,3 +1449,40 @@ func (s *Server) handleAPISettingsKeys(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
} }
} }
// Database management
func (s *Server) handleAPIDatabase(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
info := map[string]interface{}{
"path": s.dbPath,
}
if stat, err := os.Stat(s.dbPath); err == nil {
info["size_bytes"] = stat.Size()
info["size_mb"] = float64(stat.Size()) / (1024 * 1024)
}
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: "OK",
Data: info,
})
case http.MethodDelete:
// Close and recreate
if s.db != nil {
_ = s.db.Close()
}
_ = os.Remove(s.dbPath)
newDB, err := db.Open(s.dbPath)
if err != nil {
json.NewEncoder(w).Encode(APIResponse{Success: false, Message: fmt.Sprintf("Failed to recreate DB: %v", err)})
return
}
s.db = newDB
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: "Database deleted and recreated.",
})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}

View File

@ -19,23 +19,20 @@
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
color: var(--color-text-primary); color: #fff;
background: var(--color-bg-elevated); background: var(--color-brand);
border: 1px solid var(--color-border-soft); border: 1px solid var(--color-brand);
transition: background var(--transition), transition: background var(--transition),
border-color var(--transition), border-color var(--transition),
box-shadow var(--transition),
transform var(--transition-fast); transform var(--transition-fast);
} }
/* Hover glow (SUBTLE, medium intensity) */
.btn:hover { .btn:hover {
background: var(--color-bg-card); background: var(--color-brand-hover);
border-color: var(--color-brand); border-color: var(--color-brand-hover);
box-shadow: var(--shadow-glow-pink-soft); transform: none;
transform: translateY(-2px);
} }
/* Active press */ /* Active press */
@ -58,21 +55,16 @@
.btn-primary, .btn-primary,
.btn.brand, .btn.brand,
.btn.pink { .btn.pink {
background: linear-gradient( background: linear-gradient(135deg, var(--color-brand), var(--color-brand-hover));
135deg,
var(--color-brand) 0%,
var(--color-brand-hover) 90%
);
border: none; border: none;
color: #fff; color: #fff;
text-shadow: 0 0 8px rgba(255, 255, 255, 0.25); text-shadow: none;
} }
.btn-primary:hover, .btn-primary:hover,
.btn.brand:hover, .btn.brand:hover,
.btn.pink:hover { .btn.pink:hover {
box-shadow: var(--shadow-glow-pink); transform: none;
transform: translateY(-2px);
} }
@ -80,15 +72,30 @@
* SECONDARY BUTTON * SECONDARY BUTTON
* ================================ */ * ================================ */
.btn-secondary { .btn-secondary {
background: var(--color-bg-card); background: transparent;
border: 1px solid var(--color-border-soft); border: 2px solid var(--color-brand);
color: var(--color-text-primary); color: var(--color-brand);
} }
.btn-secondary:hover { .btn-secondary:hover {
border-color: var(--color-brand); border-color: var(--color-brand-hover);
color: var(--color-brand-hover);
}
/* ================================
* LIGHT PRIMARY (white bg, pink text)
* ================================ */
.btn-light-primary {
background: #ffffff;
color: var(--color-brand); color: var(--color-brand);
box-shadow: var(--shadow-glow-pink-soft); border: none;
}
.btn-light-primary:hover {
background: #ffffff;
color: var(--color-brand-hover);
border: none;
transform: none;
} }
@ -102,7 +109,7 @@
} }
.btn-small:hover { .btn-small:hover {
transform: translateY(-1px); transform: none;
} }

View File

@ -17,39 +17,24 @@
.gx-card { .gx-card {
background: var(--color-bg-card); background: var(--color-bg-card);
border: 1px solid var(--color-border-soft); border: 1px solid var(--color-border);
border-radius: var(--radius-soft); border-radius: 20px;
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-elevated); box-shadow: none;
transition: transition: none;
transform var(--transition),
box-shadow var(--transition),
border-color var(--transition);
cursor: pointer; cursor: pointer;
position: relative; 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 { .gx-card-thumb {
width: 100%; width: 100%;
aspect-ratio: var(--gx-card-thumb-ratio); aspect-ratio: var(--gx-card-thumb-ratio);
background-size: cover; background-size: cover;
background-position: center; background-position: center;
filter: brightness(0.92); filter: none;
transition: filter var(--transition-fast); transition: none;
}
.gx-card:hover .gx-card-thumb {
filter: brightness(1);
} }
.gx-card-body { .gx-card-body {
@ -62,10 +47,7 @@
.gx-card-title { .gx-card-title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
color: var(--color-text-primary);
background: linear-gradient(135deg, var(--color-text-primary), var(--color-header));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
} }
.gx-card-meta { .gx-card-meta {
@ -84,10 +66,10 @@
.gx-card-tag { .gx-card-tag {
padding: 0.2rem 0.55rem; padding: 0.2rem 0.55rem;
font-size: 0.75rem; font-size: 0.75rem;
border-radius: var(--radius); border-radius: 12px;
background: rgba(255, 79, 163, 0.08); background: rgba(255, 79, 163, 0.15);
color: var(--color-brand); color: var(--color-brand);
border: 1px solid rgba(255, 79, 163, 0.25); border: 1px solid rgba(255, 79, 163, 0.3);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }

View File

@ -14,3 +14,21 @@
.performer-card .gx-card-tags { .performer-card .gx-card-tags {
margin-top: 0.6rem; margin-top: 0.6rem;
} }
/* Harsh pink style reserved for performer cards */
.performer-card .gx-card {
background: var(--color-brand);
color: #ffffff;
border: 5px solid #ffffff;
}
.performer-card .gx-card-title,
.performer-card .gx-card-meta,
.performer-card .gx-card-tag {
color: #ffffff;
}
.performer-card .gx-card-tag {
background: rgba(255, 255, 255, 0.12);
border: 1px solid #ffffff;
}

View File

@ -14,3 +14,21 @@
.scene-card .gx-card-tags { .scene-card .gx-card-tags {
margin-top: 0.6rem; margin-top: 0.6rem;
} }
/* Harsh pink style reserved for scene cards */
.scene-card .gx-card {
background: var(--color-brand);
color: #ffffff;
border: 5px solid #ffffff;
}
.scene-card .gx-card-title,
.scene-card .gx-card-meta,
.scene-card .gx-card-tag {
color: #ffffff;
}
.scene-card .gx-card-tag {
background: rgba(255, 255, 255, 0.12);
border: 1px solid #ffffff;
}

View File

@ -9,16 +9,11 @@
* ============================================ */ * ============================================ */
.card { .card {
background: var(--color-bg-card); background: var(--color-bg-card);
border: 1px solid var(--color-border-soft); border: 1px solid var(--color-border);
border-radius: var(--radius); border-radius: 20px;
padding: 1.5rem; padding: 1.5rem;
box-shadow: var(--shadow-elevated); box-shadow: none;
transition: background var(--transition), box-shadow var(--transition); transition: none;
}
.card:hover {
background: var(--color-bg-elevated);
box-shadow: var(--shadow-glow-pink-soft);
} }
/* ============================================ /* ============================================
@ -26,26 +21,21 @@
* ============================================ */ * ============================================ */
.stat-card { .stat-card {
background: var(--color-bg-card); background: var(--color-bg-card);
border-radius: var(--radius); border-radius: 20px;
padding: 1.5rem; padding: 1.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.2rem; gap: 1.2rem;
border: 1px solid var(--color-border-soft); border: 1px solid var(--color-border);
box-shadow: var(--shadow-elevated); box-shadow: none;
transition: transform var(--transition), box-shadow var(--transition); transition: none;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-glow-pink);
} }
.stat-icon { .stat-icon {
font-size: 2.2rem; font-size: 2.2rem;
color: var(--color-brand); color: var(--color-brand);
text-shadow: 0 0 10px var(--color-brand-glow); text-shadow: none;
} }
.stat-content { .stat-content {
@ -86,9 +76,9 @@
.search-results { .search-results {
margin-top: 0.75rem; margin-top: 0.75rem;
background: var(--color-bg-card); background: var(--color-bg-card);
border: 1px solid var(--color-border-soft); border: 1px solid var(--color-border);
border-radius: var(--radius); border-radius: 20px;
box-shadow: var(--shadow-elevated); box-shadow: none;
max-height: 340px; max-height: 340px;
overflow-y: auto; overflow-y: auto;
padding: 0.5rem; padding: 0.5rem;
@ -96,13 +86,9 @@
.search-result-item { .search-result-item {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: var(--radius); border-radius: 12px;
cursor: pointer; cursor: pointer;
transition: background var(--transition); transition: none;
}
.search-result-item:hover {
background: rgba(255, 79, 163, 0.08);
} }
.search-result-title { .search-result-title {
@ -227,4 +213,3 @@
transparent transparent
); );
} }

View File

@ -31,17 +31,16 @@ select {
width: 100%; width: 100%;
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
background: var(--color-bg-card); background: var(--color-bg-elevated);
color: var(--color-text-primary); color: var(--color-text-primary);
border: 1px solid var(--color-border-soft); border: 1px solid var(--color-border);
border-radius: var(--radius); border-radius: var(--radius);
font-size: 1rem; font-size: 1rem;
outline: none; outline: none;
transition: border-color var(--transition), transition: border-color var(--transition),
box-shadow var(--transition),
background var(--transition); background var(--transition);
} }
@ -57,8 +56,7 @@ input:focus,
textarea:focus, textarea:focus,
select:focus { select:focus {
border-color: var(--color-brand); border-color: var(--color-brand);
box-shadow: 0 0 0 3px rgba(255, 79, 163, 0.18), box-shadow: none;
var(--shadow-glow-pink-soft);
background: var(--color-bg-elevated); background: var(--color-bg-elevated);
} }
@ -96,8 +94,8 @@ input[type="checkbox"] {
height: 18px; height: 18px;
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--color-border-soft); border: 1px solid var(--color-border);
background: var(--color-bg-card); background: var(--color-bg-elevated);
cursor: pointer; cursor: pointer;
position: relative; position: relative;

View File

@ -4,58 +4,85 @@
*/ */
/* ================================ /* ================================
* MAIN PAGE WRAPPING * MAIN APP SHELL
* =================================== */ * =================================== */
body { body {
display: flex; background: var(--color-bg-dark);
justify-content: center;
align-items: stretch;
min-height: 100vh; min-height: 100vh;
} }
/* Main content (center column) */ .app-shell {
.main-wrapper { min-height: 100vh;
flex: 1; display: flex;
flex-direction: column;
color: var(--color-text-primary);
}
.app-body {
width: 100%;
max-width: 1800px; max-width: 1800px;
overflow-y: auto; margin: 0 auto;
padding-bottom: 4rem; padding: 1.5rem 0 3.5rem;
}
.main-wrapper {
width: 100%;
} }
/* Shared container */ /* Shared container */
.container { .container {
max-width: 1700px;
margin: 0 auto;
padding: 0 1.5rem;
}
/* ================================
* SIDE PANELS (OPTION A scroll WITH page)
* =================================== */
.side-panel {
width: 220px;
flex-shrink: 0;
background: #000;
border-left: 1px solid var(--color-border-soft);
border-right: 1px solid var(--color-border-soft);
display: flex;
flex-direction: column;
overflow: hidden;
}
.side-panel img {
width: 100%; width: 100%;
height: auto; max-width: none;
display: block; margin: 0 auto;
object-fit: cover; padding-left: 1.25rem;
opacity: 0.75; padding-right: 1.25rem;
transition: opacity 0.25s ease;
} }
.side-panel img:hover { @media (min-width: 1200px) {
opacity: 1; .container {
padding-left: 2.5rem;
padding-right: 2.5rem;
}
}
/* Reusable elevated surface */
.surface-panel {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 1.75rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.section-kicker {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-secondary);
margin-bottom: 0.25rem;
}
.section-title {
font-size: 1.4rem;
font-weight: 700;
}
.section-hint {
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.content-stack {
display: grid;
gap: 1.5rem;
} }
@ -65,19 +92,20 @@ body {
.navbar { .navbar {
background: var(--color-bg-card); background: var(--color-bg-card);
border-bottom: 1px solid var(--color-border-soft); border-bottom: 1px solid var(--color-border);
padding: 0.75rem 0; padding: 0.85rem 0;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 40; z-index: 40;
backdrop-filter: blur(6px);
box-shadow: var(--shadow-glow-pink-soft);
} }
.nav-inner { .nav-inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%;
max-width: 1800px;
margin: 0 auto;
} }
/* Bootstrap navbar controls */ /* Bootstrap navbar controls */
@ -126,36 +154,20 @@ body {
* =================================== */ * =================================== */
.hero-section { .hero-section {
background: linear-gradient( background: var(--color-bg-card);
135deg, border: 1px solid var(--color-border);
rgba(255, 79, 163, 0.10), border-radius: 20px;
rgba(216, 132, 226, 0.05) padding: 3rem 2.5rem;
); margin-bottom: 2rem;
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-soft);
padding: 4rem 3rem;
margin-bottom: 3rem;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-glow-pink-soft); display: flex;
} flex-direction: column;
gap: 1.5rem;
/* Subtle radial neon glow (G-A) */
.hero-section::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
circle at 50% 20%,
rgba(255, 79, 163, 0.15),
rgba(255, 79, 163, 0.05) 40%,
transparent 75%
);
pointer-events: none;
} }
.hero-title { .hero-title {
font-size: 3.2rem; font-size: 2.8rem;
font-weight: 800; font-weight: 800;
background: linear-gradient( background: linear-gradient(
135deg, 135deg,
@ -170,8 +182,18 @@ body {
margin-top: 1rem; margin-top: 1rem;
font-size: 1.2rem; font-size: 1.2rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
max-width: 580px; max-width: 720px;
margin-inline: auto; }
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.hero-actions .btn,
.hero-actions .btn-secondary {
min-width: 180px;
} }
@ -182,26 +204,20 @@ body {
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.5rem; gap: 1.25rem;
margin-bottom: 3rem; margin-bottom: 2rem;
} }
.stat-card { .stat-card {
background: var(--color-bg-card); background: var(--color-bg-card);
border: 1px solid var(--color-border-soft); border: 1px solid var(--color-border);
border-radius: var(--radius); border-radius: 20px;
padding: 1.5rem; padding: 1.4rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
transition: transform 0.20s var(--transition), transition: none;
box-shadow 0.20s var(--transition);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-glow-pink);
} }
.stat-icon { .stat-icon {
@ -210,7 +226,7 @@ body {
} }
.stat-content .stat-value { .stat-content .stat-value {
font-size: 2rem; font-size: 1.9rem;
font-weight: 700; font-weight: 700;
} }
@ -241,24 +257,17 @@ body {
* RESPONSIVE BREAKPOINTS * RESPONSIVE BREAKPOINTS
* =================================== */ * =================================== */
/* --- Large screens under 1600px --- */ /* --- Small screens --- */
@media (max-width: 1600px) {
.side-panel {
width: 180px;
}
}
/* --- Hide side panels under 900px --- */
@media (max-width: 900px) { @media (max-width: 900px) {
.side-panel {
display: none;
}
.main-wrapper { .main-wrapper {
padding: 0 0.5rem; padding: 0 0.5rem;
} }
.logo-img { .logo-img {
height: 36px; height: 36px;
} }
.hero-actions {
justify-content: flex-start;
}
} }
/* --- Mobile adjustments (≤ 600px) --- */ /* --- Mobile adjustments (≤ 600px) --- */

View File

@ -0,0 +1,28 @@
/* Minimal bouncing animation for Goondex logo */
.goondex-logo-animated {
animation: logoBounce 2s ease-in-out infinite;
}
.goondex-logo-animated .nipple-left,
.goondex-logo-animated .nipple-right {
animation: nippleBounce 2s ease-in-out infinite;
}
.goondex-logo-animated .nipple-right {
animation-delay: 0.1s;
}
@keyframes logoBounce {
0% { transform: translateY(0) scaleY(1); }
20% { transform: translateY(-20px) scaleY(1.1); }
30% { transform: translateY(0) scaleY(0.7); }
40% { transform: translateY(8px) scaleY(1.15); }
100% { transform: translateY(0) scaleY(1); }
}
@keyframes nippleBounce {
0%, 100% { transform: translateY(0); }
25% { transform: translateY(-6px); }
50% { transform: translateY(0); }
75% { transform: translateY(-3px); }
}

View File

@ -540,6 +540,102 @@ main.container {
color: #ff8a8a; color: #ff8a8a;
} }
.global-loader {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.global-loader .loader-content {
background: var(--color-bg-card);
padding: 1.5rem 2rem;
border-radius: 12px;
border: 1px solid var(--color-border);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
color: var(--color-text-primary);
min-width: 280px;
justify-content: center;
}
.global-loader .logo {
display: flex;
justify-content: center;
margin-bottom: 0.5rem;
}
.global-loader .logo img,
.global-loader .logo svg {
width: 90px;
height: 55px;
filter: drop-shadow(0 2px 8px rgba(255, 95, 162, 0.3));
}
.global-loader .spinner {
width: 24px;
height: 24px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--color-brand);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.job-progress {
position: fixed;
top: 70px;
left: 50%;
transform: translateX(-50%);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 0.75rem 1rem;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3);
z-index: 1900;
min-width: 320px;
max-width: 520px;
}
.job-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
color: var(--color-text-primary);
}
.job-progress-bar {
margin-top: 0.6rem;
height: 10px;
background: var(--color-bg-elevated);
border-radius: 999px;
overflow: hidden;
}
.job-progress-fill {
height: 100%;
background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-keypoint) 100%);
width: 0%;
transition: width 0.2s ease;
}
.job-progress-message {
margin-top: 0.4rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
}
/* Detail views */ /* Detail views */
.breadcrumb { .breadcrumb {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@ -641,6 +737,17 @@ main.container {
text-decoration: underline; text-decoration: underline;
} }
.btn-link {
color: var(--color-brand);
text-decoration: none;
font-weight: 600;
}
.btn-link:hover {
color: var(--color-brand-hover);
text-decoration: underline;
}
.full-width { .full-width {
grid-column: 1 / -1; grid-column: 1 / -1;
} }

View File

@ -8,28 +8,29 @@
* =========================== */ * =========================== */
:root { :root {
/* --- BRAND IDENTITY --- */ /* --- BRAND IDENTITY --- */
--color-brand: #FF4FA3; /* Flamingo Pink (core) */ --color-brand: #FF4FA3; /* Flamingo Pulse Pink */
--color-brand-hover: #FF6AB7; /* Slightly brighter pink */ --color-brand-strong: #d74280; /* Deep Flamingo (new) */
--color-brand-glow: rgba(255, 79, 163, 0.35); /* SUBTLE neon glow */ --color-brand-hover: #d74280; /* Hover uses deeper pink */
--color-brand-glow: transparent; /* Flat theme: no glow */
/* --- TEXT --- */ /* --- TEXT --- */
--color-text-primary: #F5F5F7; --color-text-primary: #F8F8F8;
--color-text-secondary: #A0A3AB; --color-text-secondary: #9BA0A8;
--color-header: #E08FEA; --color-header: #D78BE0;
--color-keypoint: #FF6ACB; --color-keypoint: #FF66C4;
/* --- ALERTS --- */ /* --- ALERTS --- */
--color-warning: #FFAA88; --color-warning: #FFAA88;
--color-info: #7EE7E7; --color-info: #7EE7E7;
/* --- BACKGROUND LAYERS (dark only) --- */ /* --- BACKGROUND LAYERS (plum-forward dark) --- */
--color-bg-dark: #0A0A0C; --color-bg-dark: #2f2333; /* Plum base */
--color-bg-card: #151517; --color-bg-card: #3a2b40; /* Card plum */
--color-bg-elevated: #212124; --color-bg-elevated: #44344a; /* Elevated plum */
/* --- BORDERS --- */ /* --- BORDERS --- */
--color-border: #3d3d44; --color-border: #59475f;
--color-border-soft: rgba(255, 79, 163, 0.15); /* Flamingo soft border */ --color-border-soft: #59475f;
/* --- RADII --- */ /* --- RADII --- */
--radius: 12px; --radius: 12px;
@ -42,10 +43,10 @@
/* --- UI GRID --- */ /* --- UI GRID --- */
--rail-width: 180px; --rail-width: 180px;
/* --- GLOWS + SHADOWS (medium intensity only) --- */ /* --- SHADOWS (flattened) --- */
--shadow-glow-pink: 0 0 18px rgba(255, 79, 163, 0.28); --shadow-glow-pink: none;
--shadow-glow-pink-soft: 0 0 38px rgba(255, 79, 163, 0.14); --shadow-glow-pink-soft: none;
--shadow-elevated: 0 6px 22px rgba(0, 0, 0, 0.6); --shadow-elevated: none;
} }
/* =========================== /* ===========================
@ -82,12 +83,12 @@ body {
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--color-brand); background: var(--color-brand);
border-radius: 6px; border-radius: 6px;
box-shadow: var(--shadow-glow-pink-soft); box-shadow: none;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--color-brand-hover); background: var(--color-brand-hover);
box-shadow: var(--shadow-glow-pink); box-shadow: none;
} }
/* =========================== /* ===========================
@ -105,22 +106,38 @@ body {
/* Subtle glowing border */ /* Subtle glowing border */
.glow-border { .glow-border {
border: 1px solid var(--color-border-soft); border: 1px solid var(--color-border-soft);
box-shadow: var(--shadow-glow-pink-soft); box-shadow: none;
} }
/* Card elevation */ /* Card elevation */
.elevated { .elevated {
background: var(--color-bg-elevated); background: var(--color-bg-elevated);
box-shadow: var(--shadow-elevated); box-shadow: none;
} }
/* Brand glow text (subtle) */ /* Brand glow text (subtle) */
.text-glow { .text-glow {
text-shadow: 0 0 12px var(--color-brand-glow); text-shadow: none;
} }
/* Pink glow panel (subtle accent for navbar or hero) */ /* Pink glow panel (subtle accent for navbar or hero) */
.panel-glow { .panel-glow {
box-shadow: inset 0 0 60px rgba(255, 79, 163, 0.08), box-shadow: none;
0 0 22px rgba(255, 79, 163, 0.20); }
/* Global flat override to strip remaining glow from legacy components */
body, header, footer, nav, section, article,
.card, .panel, .navbar, .sidebar, .btn, .button, .badge, .chip, .tag,
input, select, textarea, button,
.modal, .dialog, .tooltip, .toast, .dropdown, .tabs, .table {
box-shadow: none !important;
text-shadow: none !important;
filter: none !important;
}
/* Absolute kill-switch for any remaining glow/shadow */
*, *::before, *::after {
box-shadow: none !important;
text-shadow: none !important;
filter: none !important;
} }

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 928 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 928 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@ -23,16 +23,17 @@
inkscape:pagecheckerboard="0" inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050" inkscape:deskcolor="#505050"
inkscape:document-units="px" inkscape:document-units="px"
inkscape:zoom="2.8284271" inkscape:zoom="0.70710678"
inkscape:cx="1382.9241" inkscape:cx="776.40325"
inkscape:cy="89.095455" inkscape:cy="353.55339"
inkscape:window-width="1920" inkscape:window-width="1920"
inkscape:window-height="1011" inkscape:window-height="1011"
inkscape:window-x="0" inkscape:window-x="0"
inkscape:window-y="0" inkscape:window-y="0"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="g9" inkscape:current-layer="g9"
showgrid="true"> showgrid="false"
showguides="true">
<inkscape:page <inkscape:page
x="0" x="0"
y="0" y="0"
@ -42,7 +43,7 @@
margin="0" margin="0"
bleed="0" /> bleed="0" />
<inkscape:page <inkscape:page
x="610" x="611"
y="0" y="0"
width="600" width="600"
height="180" height="180"
@ -65,7 +66,7 @@
opacity="0.14901961" opacity="0.14901961"
empspacing="5" empspacing="5"
enabled="true" enabled="true"
visible="true" /> visible="false" />
<inkscape:page <inkscape:page
x="1220" x="1220"
y="0" y="0"
@ -82,6 +83,38 @@
id="page20" id="page20"
margin="0" margin="0"
bleed="0" /> bleed="0" />
<inkscape:page
x="611"
y="184"
width="600"
height="180"
id="page22"
margin="0"
bleed="0" />
<inkscape:page
x="0"
y="184"
width="600"
height="180"
id="page3"
margin="0"
bleed="0" />
<inkscape:page
x="1220"
y="115.2755"
width="180"
height="110"
id="page5"
margin="0"
bleed="0" />
<inkscape:page
x="1410"
y="115.2755"
width="180"
height="110"
id="page6"
margin="0"
bleed="0" />
</sodipodi:namedview> </sodipodi:namedview>
<defs <defs
id="defs1" /> id="defs1" />
@ -89,11 +122,6 @@
inkscape:label="Layer 1" inkscape:label="Layer 1"
inkscape:groupmode="layer" inkscape:groupmode="layer"
id="layer1"> id="layer1">
<path
id="path26"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#ff5fa2;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
d="m 89.6071,50.4885 c -23.10416,0 -39.60743,16.69024 -39.60743,39.51149 0,22.82124 16.50328,39.51151 39.60743,39.51151 22.06683,0 39.04336,-15.65212 39.04336,-37.90755 v -3.39592 h -40.6473 v 8.57996 h 30.08354 c -1.13163,13.67388 -12.63408,23.85962 -28.0997,23.85962 -17.6346,0 -30.08354,-12.82442 -30.08354,-30.64762 0,-17.72891 12.44569,-30.45956 29.89167,-30.45956 12.91947,0 23.57566,7.07122 26.68766,16.59581 h 10.75176 C 123.27385,61.70793 108.84487,50.48851 89.6071,50.4885 Z m 240.25392,1.32 v 59.3152 L 284.12556,52.28048 h -9.23996 v 75.53498 h 9.89995 V 68.50023 l 45.73537,58.84324 h 9.3359 V 51.80846 Z m 18.51061,0.47198 v 75.53499 h 26.7796 c 26.0276,0 41.1193,-15.1812 41.1193,-37.71954 0,-0.52548 -0.01,-1.04807 -0.027,-1.56558 -0.041,-1.2646 -0.1283,-2.50346 -0.2647,-3.71824 h -0.01 c -2.2059,-19.60648 -16.8839,-32.53165 -40.82,-32.53165 z m 74.6754,0 v 75.53499 h 54.5072 v -8.77182 h -44.6073 V 93.5839 h 40.4593 v -8.77179 h -40.4593 V 61.04843 h 43.7593 v -8.76795 z m 60.6582,0 26.3116,37.34349 -27.2555,38.1915 h 11.5998 l 21.9717,-30.93156 21.8797,30.93156 h 11.7878 l -27.3476,-38.47545 26.4036,-37.05954 h -11.5039 l -21.0277,29.79956 -20.8436,-29.79956 z m -125.4337,8.86387 h 17.1637 c 17.2271,0 28.4182,8.55424 30.4864,23.66776 h -23.3339 v 8.77179 h 23.5335 c 0.098,-1.12825 0.1497,-2.29113 0.1497,-3.48797 0,1.19665 -0.052,2.35989 -0.1497,3.48797 -1.4059,16.20741 -12.7883,25.27173 -30.686,25.27173 h -17.1637 z"
sodipodi:nodetypes="ssssccccsssccscccccccccccccsscccsccccccccccccccccccccccccccccsccccccscc" />
<path <path
d="m 206.54093,52.264773 h -9.90177 v 75.536347 h 9.90177 z" d="m 206.54093,52.264773 h -9.90177 v 75.536347 h 9.90177 z"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';display:none;fill:#808000;stroke-width:7.85855" style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';display:none;fill:#808000;stroke-width:7.85855"
@ -134,54 +162,144 @@
<g <g
inkscape:groupmode="layer" inkscape:groupmode="layer"
id="layer2" id="layer2"
inkscape:label="Layer 2"> inkscape:label="Layer 2" />
<path
d="m 699.60709,50.4885 c -23.10416,0 -39.60742,16.69024 -39.60742,39.51149 0,22.82124 16.50328,39.51151 39.60742,39.51151 22.06684,0 39.04337,-15.65212 39.04337,-37.90755 v -3.39592 h -40.64731 v 8.57996 h 30.08355 c -1.13164,13.67388 -12.63408,23.85962 -28.09971,23.85962 -17.6346,0 -30.08354,-12.82442 -30.08354,-30.64762 0,-17.72891 12.4457,-30.45956 29.89167,-30.45956 12.91948,0 23.57567,7.07122 26.68767,16.59581 h 10.75176 C 733.27385,61.70793 718.84487,50.48851 699.60709,50.4885 Z m 240.25393,1.32 v 59.3152 L 894.12555,52.28048 h -9.23995 v 75.53498 h 9.89995 V 68.50023 l 45.73536,58.84324 h 9.3359 V 51.80846 Z"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#483737;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
id="path1"
sodipodi:nodetypes="ssssccccsssccsccccccccccc" />
<path
id="path2"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#8a6f91;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
d="m 958.37162,52.28048 v 75.53499 h 26.7796 c 26.02758,0 41.11928,-15.1812 41.11928,-37.71954 0,-0.52548 -0.01,-1.04807 -0.027,-1.56558 -0.041,-1.2646 -0.1283,-2.50346 -0.2647,-3.71824 h -0.01 c -2.2059,-19.60648 -16.8839,-32.53165 -40.81997,-32.53165 z m 9.8999,8.86387 h 17.16371 c 17.22707,0 28.41817,8.55424 30.48637,23.66776 h -23.33388 v 8.77179 h 23.53348 c 0.098,-1.12825 0.1497,-2.29113 0.1497,-3.48797 0,1.19665 -0.052,2.35989 -0.1497,3.48797 -1.4059,16.20741 -12.7883,25.27173 -30.68597,25.27173 h -17.16371 z"
sodipodi:nodetypes="ccsscccsccsccccccscc" />
</g>
<path
d="m 1033.047,52.28048 v 75.53499 h 54.5072 v -8.77182 h -44.6073 V 93.5839 h 40.4593 v -8.77179 h -40.4593 V 61.04843 h 43.7593 v -8.76795 z m 60.6582,0 26.3116,37.34349 -27.2555,38.1915 h 11.5998 l 21.9717,-30.93156 21.8797,30.93156 h 11.7878 l -27.3476,-38.47545 26.4036,-37.05954 h -11.5039 l -21.0277,29.79956 -20.8436,-29.79956 z"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#ff5fa2;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
id="path1-1" />
<g <g
inkscape:groupmode="layer" inkscape:groupmode="layer"
id="g9" id="g9"
inkscape:label="Titty"> inkscape:label="Titty">
<path
id="path13"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';stroke-width:7.85855;fill:#ff5fa2;fill-opacity:1"
d="M 173.33789 50.472656 C 150.42237 50.472656 133.54297 67.165118 133.54297 89.986328 C 133.54297 112.80755 150.51808 129.49805 173.43359 129.49805 C 184.72527 129.49805 194.54994 125.44543 201.61328 118.60352 C 208.69985 125.44543 218.55013 129.49805 229.8418 129.49805 C 252.75733 129.49805 269.63672 112.80755 269.63672 89.986328 C 269.63672 67.165118 252.66358 50.472656 229.74805 50.472656 C 218.45638 50.472656 208.62975 54.525269 201.56641 61.367188 C 194.47983 54.525267 184.62956 50.472656 173.33789 50.472656 z M 173.33789 59.525391 C 182.39788 59.525391 190.21021 63.008763 195.58789 68.896484 C 198.21228 71.769779 200.25728 75.215888 201.58398 79.109375 C 202.9042 75.210676 204.94121 71.760371 207.55859 68.884766 C 212.91125 63.004031 220.69398 59.525391 229.74805 59.525391 C 247.00542 59.525391 259.73633 72.163146 259.73633 89.986328 C 259.73633 107.71522 247.00486 120.44531 229.8418 120.44531 C 220.78934 120.44531 212.98272 116.94263 207.60547 111.05273 C 204.97114 108.16726 202.91866 104.70856 201.58984 100.80859 C 200.2638 104.70381 198.21994 108.15962 195.5957 111.04297 C 190.22932 116.93923 182.44196 120.44531 173.43359 120.44531 C 156.17623 120.44531 143.44531 107.71522 143.44531 89.986328 C 143.44531 72.163146 156.08053 59.525391 173.33789 59.525391 z M 172.58594 100.67578 C 170.48224 100.6759 168.77728 102.38262 168.7793 104.48633 C 168.77939 106.58856 170.48372 108.2909 172.58594 108.29102 C 174.68815 108.2909 176.39244 106.58856 176.39258 104.48633 C 176.39458 102.38262 174.68964 100.6759 172.58594 100.67578 z M 229.05078 100.67578 C 226.94706 100.6759 225.24216 102.38262 225.24414 104.48633 C 225.24427 106.58856 226.94852 108.2909 229.05078 108.29102 C 231.153 108.2909 232.8573 106.58856 232.85742 104.48633 C 232.85942 102.38262 231.15448 100.6759 229.05078 100.67578 z " />
<g <g
id="g20"> id="g8">
<path <path
id="path19" id="path26"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#483737;fill-opacity:1;stroke-width:7.85855" style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#ff5fa2;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
d="m 783.33779,50.472656 c -22.91552,0 -39.79492,16.692462 -39.79492,39.513672 0,22.821222 16.97511,39.511722 39.89062,39.511722 11.29168,0 21.11635,-4.05262 28.17969,-10.89453 7.08657,6.84191 16.93685,10.89453 28.22852,10.89453 22.91553,0 39.79492,-16.6905 39.79492,-39.511722 0,-22.82121 -16.97314,-39.513672 -39.88867,-39.513672 -11.29167,0 -21.1183,4.052613 -28.18164,10.894532 -7.08658,-6.841921 -16.93685,-10.894532 -28.22852,-10.894532 z m 0,9.052735 c 9.05999,0 16.87232,3.483372 22.25,9.371093 2.62439,2.873295 4.66939,6.319404 5.99609,10.212891 1.32022,-3.898699 3.35723,-7.349004 5.97461,-10.224609 5.35266,-5.880735 13.13539,-9.359375 22.18946,-9.359375 17.25737,0 29.98828,12.637755 29.98828,30.460937 0,17.728892 -12.73147,30.458982 -29.89453,30.458982 -9.05246,0 -16.85908,-3.50268 -22.23633,-9.39258 -2.63433,-2.88547 -4.68681,-6.34417 -6.01563,-10.24414 -1.32604,3.89522 -3.3699,7.35103 -5.99414,10.23438 -5.36638,5.89626 -13.15374,9.40234 -22.16211,9.40234 -17.25736,0 -29.98828,-12.73009 -29.98828,-30.458982 0,-17.823182 12.63522,-30.460937 29.89258,-30.460937 z" d="m 89.6071,50.4885 c -23.10416,0 -39.60743,16.69024 -39.60743,39.51149 0,22.82124 16.50328,39.51151 39.60743,39.51151 22.06683,0 39.04336,-15.65212 39.04336,-37.90755 v -3.39592 h -40.6473 v 8.57996 h 30.08354 c -1.13163,13.67388 -12.63408,23.85962 -28.0997,23.85962 -17.6346,0 -30.08354,-12.82442 -30.08354,-30.64762 0,-17.72891 12.44569,-30.45956 29.89167,-30.45956 12.91947,0 23.57566,7.07122 26.68766,16.59581 h 10.75176 C 123.27385,61.70793 108.84487,50.48851 89.6071,50.4885 Z m 240.25392,1.32 v 59.3152 L 284.12556,52.28048 h -9.23996 v 75.53498 h 9.89995 V 68.50023 l 45.73537,58.84324 h 9.3359 V 51.80846 Z m 18.51061,0.47198 v 75.53499 h 26.7796 c 26.0276,0 41.1193,-15.1812 41.1193,-37.71954 0,-0.52548 -0.01,-1.04807 -0.027,-1.56558 -0.041,-1.2646 -0.1283,-2.50346 -0.2647,-3.71824 h -0.01 c -2.2059,-19.60648 -16.8839,-32.53165 -40.82,-32.53165 z m 74.6754,0 v 75.53499 h 54.5072 v -8.77182 h -44.6073 V 93.5839 h 40.4593 v -8.77179 h -40.4593 V 61.04843 h 43.7593 v -8.76795 z m 60.6582,0 26.3116,37.34349 -27.2555,38.1915 h 11.5998 l 21.9717,-30.93156 21.8797,30.93156 h 11.7878 l -27.3476,-38.47545 26.4036,-37.05954 h -11.5039 l -21.0277,29.79956 -20.8436,-29.79956 z m -125.4337,8.86387 h 17.1637 c 17.2271,0 28.4182,8.55424 30.4864,23.66776 h -23.3339 v 8.77179 h 23.5335 c 0.098,-1.12825 0.1497,-2.29113 0.1497,-3.48797 0,1.19665 -0.052,2.35989 -0.1497,3.48797 -1.4059,16.20741 -12.7883,25.27173 -30.686,25.27173 h -17.1637 z"
sodipodi:nodetypes="ssscssscssscssssscssss" /> sodipodi:nodetypes="ssssccccsssccscccccccccccccsscccsccccccccccccccccccccccccccccsccccccscc" />
<path <path
d="m 782.58584,100.67578 c -2.1037,1.2e-4 -3.80866,1.70684 -3.80664,3.81055 9e-5,2.10223 1.70442,3.80457 3.80664,3.80469 2.10221,-1.2e-4 3.8065,-1.70246 3.80664,-3.80469 0.002,-2.10371 -1.70294,-3.81043 -3.80664,-3.81055 z m 56.46484,0 c -2.10372,1.2e-4 -3.80862,1.70684 -3.80664,3.81055 1.3e-4,2.10223 1.70438,3.80457 3.80664,3.80469 2.10222,-1.2e-4 3.80652,-1.70246 3.80664,-3.80469 0.002,-2.10371 -1.70294,-3.81043 -3.80664,-3.81055 z" id="path13"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#8a6f91;fill-opacity:1;stroke-width:7.85855" style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';stroke-width:7.85855;fill:#ff5fa2;fill-opacity:1"
id="path1-52" /> d="M 173.33789 50.472656 C 150.42237 50.472656 133.54297 67.165118 133.54297 89.986328 C 133.54297 112.80755 150.51808 129.49805 173.43359 129.49805 C 184.72527 129.49805 194.54994 125.44543 201.61328 118.60352 C 208.69985 125.44543 218.55013 129.49805 229.8418 129.49805 C 252.75733 129.49805 269.63672 112.80755 269.63672 89.986328 C 269.63672 67.165118 252.66358 50.472656 229.74805 50.472656 C 218.45638 50.472656 208.62975 54.525269 201.56641 61.367188 C 194.47983 54.525267 184.62956 50.472656 173.33789 50.472656 z M 173.33789 59.525391 C 182.39788 59.525391 190.21021 63.008763 195.58789 68.896484 C 198.21228 71.769779 200.25728 75.215888 201.58398 79.109375 C 202.9042 75.210676 204.94121 71.760371 207.55859 68.884766 C 212.91125 63.004031 220.69398 59.525391 229.74805 59.525391 C 247.00542 59.525391 259.73633 72.163146 259.73633 89.986328 C 259.73633 107.71522 247.00486 120.44531 229.8418 120.44531 C 220.78934 120.44531 212.98272 116.94263 207.60547 111.05273 C 204.97114 108.16726 202.91866 104.70856 201.58984 100.80859 C 200.2638 104.70381 198.21994 108.15962 195.5957 111.04297 C 190.22932 116.93923 182.44196 120.44531 173.43359 120.44531 C 156.17623 120.44531 143.44531 107.71522 143.44531 89.986328 C 143.44531 72.163146 156.08053 59.525391 173.33789 59.525391 z M 172.58594 100.67578 C 170.48224 100.6759 168.77728 102.38262 168.7793 104.48633 C 168.77939 106.58856 170.48372 108.2909 172.58594 108.29102 C 174.68815 108.2909 176.39244 106.58856 176.39258 104.48633 C 176.39458 102.38262 174.68964 100.6759 172.58594 100.67578 z M 229.05078 100.67578 C 226.94706 100.6759 225.24216 102.38262 225.24414 104.48633 C 225.24427 106.58856 226.94852 108.2909 229.05078 108.29102 C 231.153 108.2909 232.8573 106.58856 232.85742 104.48633 C 232.85942 102.38262 231.15448 100.6759 229.05078 100.67578 z " />
</g>
<g
id="g11">
<path
d="m 700.6071,50.496422 c -23.10416,0 -39.60742,16.69024 -39.60742,39.51149 0,22.821238 16.50328,39.511508 39.60742,39.511508 22.06684,0 39.04337,-15.65212 39.04337,-37.907548 v -3.39592 h -40.64731 v 8.57996 h 30.08355 c -1.13164,13.673878 -12.63408,23.859618 -28.09971,23.859618 -17.6346,0 -30.08354,-12.82442 -30.08354,-30.647618 0,-17.72891 12.4457,-30.45956 29.89167,-30.45956 12.91948,0 23.57567,7.07122 26.68767,16.59581 h 10.75176 c -3.9607,-14.42831 -18.38968,-25.64773 -37.62746,-25.64774 z m 240.25393,1.32 V 111.13162 L 895.12556,52.288402 h -9.23995 v 75.534978 h 9.89995 V 68.508152 l 45.73536,58.843238 h 9.3359 V 51.816382 Z"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#483737;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
id="path1"
sodipodi:nodetypes="ssssccccsssccsccccccccccc" />
<path
id="path2"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#8a6f91;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
d="m 959.37163,52.288402 v 75.534988 h 26.7796 c 26.02757,0 41.11927,-15.1812 41.11927,-37.719538 0,-0.52548 -0.01,-1.04807 -0.027,-1.56558 -0.041,-1.2646 -0.1283,-2.50346 -0.2647,-3.71824 h -0.01 c -2.2059,-19.60648 -16.8839,-32.53165 -40.81995,-32.53165 z m 9.8999,8.86387 h 17.16371 c 17.22706,0 28.41816,8.55424 30.48636,23.66776 h -23.33387 v 8.77179 h 23.53347 c 0.098,-1.12825 0.1497,-2.29113 0.1497,-3.48797 0,1.19665 -0.052,2.35989 -0.1497,3.48797 -1.4059,16.207408 -12.7883,25.271728 -30.68596,25.271728 h -17.16371 z"
sodipodi:nodetypes="ccsscccsccsccccccscc" />
<path
d="m 1034.047,52.288402 v 75.534988 h 54.5072 v -8.77182 h -44.6073 V 93.591822 h 40.4593 v -8.77179 h -40.4593 v -23.76368 h 43.7593 v -8.76795 z m 60.6582,0 26.3116,37.34349 -27.2555,38.191498 h 11.5998 l 21.9717,-30.931558 21.8797,30.931558 h 11.7878 l -27.3476,-38.475448 26.4036,-37.05954 h -11.5039 l -21.0277,29.79956 -20.8436,-29.79956 z"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#ff5fa2;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
id="path1-1" />
<g
id="g20"
transform="translate(1.000015,0.007922)">
<path
id="path19"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#483737;fill-opacity:1;stroke-width:7.85855"
d="m 783.33779,50.472656 c -22.91552,0 -39.79492,16.692462 -39.79492,39.513672 0,22.821222 16.97511,39.511722 39.89062,39.511722 11.29168,0 21.11635,-4.05262 28.17969,-10.89453 7.08657,6.84191 16.93685,10.89453 28.22852,10.89453 22.91553,0 39.79492,-16.6905 39.79492,-39.511722 0,-22.82121 -16.97314,-39.513672 -39.88867,-39.513672 -11.29167,0 -21.1183,4.052613 -28.18164,10.894532 -7.08658,-6.841921 -16.93685,-10.894532 -28.22852,-10.894532 z m 0,9.052735 c 9.05999,0 16.87232,3.483372 22.25,9.371093 2.62439,2.873295 4.66939,6.319404 5.99609,10.212891 1.32022,-3.898699 3.35723,-7.349004 5.97461,-10.224609 5.35266,-5.880735 13.13539,-9.359375 22.18946,-9.359375 17.25737,0 29.98828,12.637755 29.98828,30.460937 0,17.728892 -12.73147,30.458982 -29.89453,30.458982 -9.05246,0 -16.85908,-3.50268 -22.23633,-9.39258 -2.63433,-2.88547 -4.68681,-6.34417 -6.01563,-10.24414 -1.32604,3.89522 -3.3699,7.35103 -5.99414,10.23438 -5.36638,5.89626 -13.15374,9.40234 -22.16211,9.40234 -17.25736,0 -29.98828,-12.73009 -29.98828,-30.458982 0,-17.823182 12.63522,-30.460937 29.89258,-30.460937 z"
sodipodi:nodetypes="ssscssscssscssssscssss" />
<path
d="m 782.58584,100.67578 c -2.1037,1.2e-4 -3.80866,1.70684 -3.80664,3.81055 9e-5,2.10223 1.70442,3.80457 3.80664,3.80469 2.10221,-1.2e-4 3.8065,-1.70246 3.80664,-3.80469 0.002,-2.10371 -1.70294,-3.81043 -3.80664,-3.81055 z m 56.46484,0 c -2.10372,1.2e-4 -3.80862,1.70684 -3.80664,3.81055 1.3e-4,2.10223 1.70438,3.80457 3.80664,3.80469 2.10222,-1.2e-4 3.80652,-1.70246 3.80664,-3.80469 0.002,-2.10371 -1.70294,-3.81043 -3.80664,-3.81055 z"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#8a6f91;fill-opacity:1;stroke-width:7.85855"
id="path1-52" />
</g>
</g>
<g
id="g5"
transform="translate(-610.99999,336.73506)">
<path
d="m 700.6071,-102.23864 c -23.10416,0 -39.60742,16.690237 -39.60742,39.511487 0,22.82124 16.50328,39.51151 39.60742,39.51151 22.06684,0 39.04337,-15.65212 39.04337,-37.90755 v -3.39592 h -40.64731 v 8.57996 h 30.08355 c -1.13164,13.67388 -12.63408,23.85962 -28.09971,23.85962 -17.6346,0 -30.08354,-12.82442 -30.08354,-30.64762 0,-17.72891 12.4457,-30.45956 29.89167,-30.45956 12.91948,0 23.57567,7.07122 26.68767,16.59581 h 10.75176 c -3.9607,-14.42831 -18.38968,-25.647727 -37.62746,-25.647737 z m 240.25393,1.32 v 59.315197 l -45.73547,-58.843217 h -9.23995 v 75.534977 h 9.89995 v -59.31523 l 45.73536,58.84324 h 9.3359 v -75.535007 z"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#ff5fa2;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
id="path31"
sodipodi:nodetypes="ssssccccsssccsccccccccccc" />
<path
id="path32"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#8a6f91;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
d="m 959.37163,-100.44666 v 75.534987 h 26.7796 c 26.02757,0 41.11927,-15.1812 41.11927,-37.71954 0,-0.52548 -0.01,-1.04807 -0.027,-1.56558 -0.041,-1.2646 -0.1283,-2.50346 -0.2647,-3.71824 h -0.01 c -2.2059,-19.60648 -16.8839,-32.531647 -40.81995,-32.531647 z m 9.8999,8.863867 h 17.16371 c 17.22706,0 28.41816,8.55424 30.48636,23.66776 h -23.33387 v 8.77179 h 23.53347 c 0.098,-1.12825 0.1497,-2.29113 0.1497,-3.48797 0,1.19665 -0.052,2.35989 -0.1497,3.48797 -1.4059,16.20741 -12.7883,25.27173 -30.68596,25.27173 h -17.16371 z"
sodipodi:nodetypes="ccsscccsccsccccccscc" />
<path
d="m 1034.047,-100.44666 v 75.534987 h 54.5072 v -8.77182 h -44.6073 v -25.45975 h 40.4593 v -8.77179 h -40.4593 v -23.76368 h 43.7593 v -8.767947 z m 60.6582,0 26.3116,37.343487 -27.2555,38.1915 h 11.5998 l 21.9717,-30.93156 21.8797,30.93156 h 11.7878 l -27.3476,-38.47545 26.4036,-37.059537 h -11.5039 l -21.0277,29.799557 -20.8436,-29.799557 z"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#8a6f91;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
id="path33" />
<g
id="g35"
transform="translate(1.000015,-152.72714)"
style="fill:#ff5fa2;fill-opacity:1">
<path
id="path34"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#ff5fa2;fill-opacity:1;stroke-width:7.85855"
d="m 783.33779,50.472656 c -22.91552,0 -39.79492,16.692462 -39.79492,39.513672 0,22.821222 16.97511,39.511722 39.89062,39.511722 11.29168,0 21.11635,-4.05262 28.17969,-10.89453 7.08657,6.84191 16.93685,10.89453 28.22852,10.89453 22.91553,0 39.79492,-16.6905 39.79492,-39.511722 0,-22.82121 -16.97314,-39.513672 -39.88867,-39.513672 -11.29167,0 -21.1183,4.052613 -28.18164,10.894532 -7.08658,-6.841921 -16.93685,-10.894532 -28.22852,-10.894532 z m 0,9.052735 c 9.05999,0 16.87232,3.483372 22.25,9.371093 2.62439,2.873295 4.66939,6.319404 5.99609,10.212891 1.32022,-3.898699 3.35723,-7.349004 5.97461,-10.224609 5.35266,-5.880735 13.13539,-9.359375 22.18946,-9.359375 17.25737,0 29.98828,12.637755 29.98828,30.460937 0,17.728892 -12.73147,30.458982 -29.89453,30.458982 -9.05246,0 -16.85908,-3.50268 -22.23633,-9.39258 -2.63433,-2.88547 -4.68681,-6.34417 -6.01563,-10.24414 -1.32604,3.89522 -3.3699,7.35103 -5.99414,10.23438 -5.36638,5.89626 -13.15374,9.40234 -22.16211,9.40234 -17.25736,0 -29.98828,-12.73009 -29.98828,-30.458982 0,-17.823182 12.63522,-30.460937 29.89258,-30.460937 z"
sodipodi:nodetypes="ssscssscssscssssscssss" />
<path
d="m 782.58584,100.67578 c -2.1037,1.2e-4 -3.80866,1.70684 -3.80664,3.81055 9e-5,2.10223 1.70442,3.80457 3.80664,3.80469 2.10221,-1.2e-4 3.8065,-1.70246 3.80664,-3.80469 0.002,-2.10371 -1.70294,-3.81043 -3.80664,-3.81055 z m 56.46484,0 c -2.10372,1.2e-4 -3.80862,1.70684 -3.80664,3.81055 1.3e-4,2.10223 1.70438,3.80457 3.80664,3.80469 2.10222,-1.2e-4 3.80652,-1.70246 3.80664,-3.80469 0.002,-2.10371 -1.70294,-3.81043 -3.80664,-3.81055 z"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#ff5fa2;fill-opacity:1;stroke-width:7.85855"
id="path35" />
</g>
</g> </g>
<path <path
id="path13-4" id="path13-4"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#ff5fa2;fill-opacity:1;stroke-width:9.80478" style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#ff5fa2;fill-opacity:1;stroke-width:9.80478"
d="M 1274.6505,5 C 1246.0598,5 1225,25.826486 1225,54.299551 c 0,28.473073 21.1791,49.297109 49.7699,49.297109 14.0881,0 26.3458,-5.056266 35.1585,-13.592646 8.8417,8.53638 21.1312,13.592646 35.2194,13.592646 28.5909,0 49.6506,-20.824036 49.6506,-49.297109 C 1394.7984,25.826486 1373.6218,5 1345.0311,5 1330.9429,5 1318.6826,10.056275 1309.8699,18.592651 1301.0283,10.056273 1288.7387,5 1274.6505,5 Z m 0,11.294718 c 11.3038,0 21.0508,4.346057 27.7603,11.69192 3.2744,3.584891 5.8258,7.884457 7.4811,12.742197 1.6471,-4.864244 4.1887,-9.169044 7.4542,-12.756817 6.6784,-7.337146 16.3885,-11.6773 27.685,-11.6773 21.5312,0 37.415,15.767597 37.415,38.004833 0,22.119593 -15.8844,38.002393 -37.2982,38.002393 -11.2943,0 -21.0343,-4.37015 -27.7432,-11.71873 -3.2869,-3.60007 -5.8476,-7.915358 -7.5056,-12.781188 -1.6544,4.859897 -4.2044,9.171568 -7.4786,12.769018 -6.6954,7.35651 -16.4112,11.7309 -27.6506,11.7309 -21.5314,0 -37.4152,-15.88281 -37.4152,-38.002393 0,-22.237236 15.7644,-38.004833 37.2958,-38.004833 z m -0.9383,51.341619 c -2.6247,1.49e-4 -4.7519,2.129552 -4.7493,4.754267 10e-5,2.62286 2.1265,4.74679 4.7493,4.74695 2.623,-1.6e-4 4.7491,-2.12409 4.7495,-4.74695 0,-2.624715 -2.1248,-4.754118 -4.7495,-4.754267 z m 70.4489,0 c -2.6247,1.49e-4 -4.7518,2.129552 -4.7495,4.754267 2e-4,2.62286 2.1265,4.74679 4.7495,4.74695 2.6228,-1.6e-4 4.7492,-2.12409 4.7493,-4.74695 0,-2.624715 -2.1246,-4.754118 -4.7493,-4.754267 z" /> d="M 1274.6505,5 C 1246.0598,5 1225,25.826486 1225,54.299551 c 0,28.473073 21.1791,49.297109 49.7699,49.297109 14.0881,0 26.3458,-5.056266 35.1585,-13.592646 8.8417,8.53638 21.1312,13.592646 35.2194,13.592646 28.5909,0 49.6506,-20.824036 49.6506,-49.297109 C 1394.7984,25.826486 1373.6218,5 1345.0311,5 1330.9429,5 1318.6826,10.056275 1309.8699,18.592651 1301.0283,10.056273 1288.7387,5 1274.6505,5 Z m 0,11.294718 c 11.3038,0 21.0508,4.346057 27.7603,11.69192 3.2744,3.584891 5.8258,7.884457 7.4811,12.742197 1.6471,-4.864244 4.1887,-9.169044 7.4542,-12.756817 6.6784,-7.337146 16.3885,-11.6773 27.685,-11.6773 21.5312,0 37.415,15.767597 37.415,38.004833 0,22.119593 -15.8844,38.002393 -37.2982,38.002393 -11.2943,0 -21.0343,-4.37015 -27.7432,-11.71873 -3.2869,-3.60007 -5.8476,-7.915358 -7.5056,-12.781188 -1.6544,4.859897 -4.2044,9.171568 -7.4786,12.769018 -6.6954,7.35651 -16.4112,11.7309 -27.6506,11.7309 -21.5314,0 -37.4152,-15.88281 -37.4152,-38.002393 0,-22.237236 15.7644,-38.004833 37.2958,-38.004833 z m -0.9383,51.341619 c -2.6247,1.49e-4 -4.7519,2.129552 -4.7493,4.754267 10e-5,2.62286 2.1265,4.74679 4.7493,4.74695 2.623,-1.6e-4 4.7491,-2.12409 4.7495,-4.74695 0,-2.624715 -2.1248,-4.754118 -4.7495,-4.754267 z m 70.4489,0 c -2.6247,1.49e-4 -4.7518,2.129552 -4.7495,4.754267 2e-4,2.62286 2.1265,4.74679 4.7495,4.74695 2.6228,-1.6e-4 4.7492,-2.12409 4.7493,-4.74695 0,-2.624715 -2.1246,-4.754118 -4.7493,-4.754267 z" />
<g
id="g22">
<path
id="path22"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#483737;fill-opacity:1;stroke-width:9.80478"
d="m 1464.5026,5 c -28.5907,0 -49.6505,20.826486 -49.6505,49.299551 0,28.473073 21.1791,49.297109 49.7699,49.297109 14.0881,0 26.3458,-5.056266 35.1585,-13.592646 8.8417,8.53638 21.1312,13.592646 35.2194,13.592646 28.5909,0 49.6506,-20.824036 49.6506,-49.297109 C 1584.6505,25.826486 1563.4739,5 1534.8832,5 1520.795,5 1508.5347,10.056275 1499.722,18.592651 1490.8804,10.056273 1478.5908,5 1464.5026,5 Z m 0,11.294718 c 11.3038,0 21.0508,4.346057 27.7603,11.69192 3.2744,3.584891 5.8258,7.884457 7.4811,12.742197 1.6471,-4.864244 4.1887,-9.169044 7.4542,-12.756817 6.6784,-7.337146 16.3885,-11.6773 27.685,-11.6773 21.5312,0 37.415,15.767597 37.415,38.004833 0,22.119593 -15.8844,38.002393 -37.2982,38.002393 -11.2943,0 -21.0343,-4.37015 -27.7432,-11.71873 -3.2869,-3.60007 -5.8476,-7.915358 -7.5056,-12.781188 -1.6544,4.859897 -4.2044,9.171568 -7.4786,12.769018 -6.6954,7.35651 -16.4112,11.7309 -27.6506,11.7309 -21.5314,0 -37.4152,-15.88281 -37.4152,-38.002393 0,-22.237236 15.7644,-38.004833 37.2958,-38.004833 z"
sodipodi:nodetypes="ssscssscssscssssscssss" />
<path
d="m 1463.5643,67.636337 c -2.6247,1.49e-4 -4.7519,2.129552 -4.7493,4.754267 10e-5,2.62286 2.1265,4.74679 4.7493,4.74695 2.623,-1.6e-4 4.7491,-2.12409 4.7495,-4.74695 0,-2.624715 -2.1248,-4.754118 -4.7495,-4.754267 z m 70.4489,0 c -2.6247,1.49e-4 -4.7518,2.129552 -4.7495,4.754267 2e-4,2.62286 2.1265,4.74679 4.7495,4.74695 2.6228,-1.6e-4 4.7492,-2.12409 4.7493,-4.74695 0,-2.624715 -2.1246,-4.754118 -4.7493,-4.754267 z"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#8a6f91;fill-opacity:1;stroke-width:9.80478"
id="path1-8" />
</g>
<g
id="g10">
<path
d="m 700.6071,234.49642 c -23.10416,0 -39.60742,16.69024 -39.60742,39.51149 0,22.82124 16.50328,39.51151 39.60742,39.51151 22.06684,0 39.04337,-15.65212 39.04337,-37.90755 v -3.39592 h -40.64731 v 8.57996 h 30.08355 c -1.13164,13.67388 -12.63408,23.85962 -28.09971,23.85962 -17.6346,0 -30.08354,-12.82442 -30.08354,-30.64762 0,-17.72891 12.4457,-30.45956 29.89167,-30.45956 12.91948,0 23.57567,7.07122 26.68767,16.59581 h 10.75176 c -3.9607,-14.42831 -18.38968,-25.64773 -37.62746,-25.64774 z m 240.25393,1.32 v 59.3152 L 895.12556,236.2884 h -9.23995 v 75.53498 h 9.89995 v -59.31523 l 45.73536,58.84324 h 9.3359 v -75.53501 z"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#e3bea2;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
id="path23"
sodipodi:nodetypes="ssssccccsssccsccccccccccc" />
<path
id="path24"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#e880a8;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
d="m 959.37163,236.2884 v 75.53499 h 26.7796 c 26.02757,0 41.11927,-15.1812 41.11927,-37.71954 0,-0.52548 -0.01,-1.04807 -0.027,-1.56558 -0.041,-1.2646 -0.1283,-2.50346 -0.2647,-3.71824 h -0.01 c -2.2059,-19.60648 -16.8839,-32.53165 -40.81995,-32.53165 z m 9.8999,8.86387 h 17.16371 c 17.22706,0 28.41816,8.55424 30.48636,23.66776 h -23.33387 v 8.77179 h 23.53347 c 0.098,-1.12825 0.1497,-2.29113 0.1497,-3.48797 0,1.19665 -0.052,2.35989 -0.1497,3.48797 -1.4059,16.20741 -12.7883,25.27173 -30.68596,25.27173 h -17.16371 z"
sodipodi:nodetypes="ccsscccsccsccccccscc" />
<path
d="m 1034.047,236.2884 v 75.53499 h 54.5072 v -8.77182 h -44.6073 v -25.45975 h 40.4593 v -8.77179 h -40.4593 v -23.76368 h 43.7593 v -8.76795 z m 60.6582,0 26.3116,37.34349 -27.2555,38.1915 h 11.5998 l 21.9717,-30.93156 21.8797,30.93156 h 11.7878 l -27.3476,-38.47545 26.4036,-37.05954 h -11.5039 l -21.0277,29.79956 -20.8436,-29.79956 z"
style="font-size:48px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans';fill:#ff5fa2;fill-opacity:1;stroke-width:0;stroke-linecap:round;paint-order:markers fill stroke"
id="path25" />
<path
id="path27"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#e3bea2;fill-opacity:1;stroke-width:7.85855"
d="m 784.3378,234.48058 c -22.91552,0 -39.79492,16.69246 -39.79492,39.51367 0,22.82122 16.97511,39.51172 39.89062,39.51172 11.29168,0 21.11635,-4.05262 28.1797,-10.89453 7.08657,6.84191 16.93685,10.89453 28.22852,10.89453 22.91552,0 39.79492,-16.6905 39.79492,-39.51172 0,-22.82121 -16.97314,-39.51367 -39.88867,-39.51367 -11.29167,0 -21.1183,4.05261 -28.18165,10.89453 -7.08658,-6.84192 -16.93685,-10.89453 -28.22852,-10.89453 z m 0,9.05273 c 9.05999,0 16.87232,3.48337 22.25,9.37109 2.62439,2.8733 4.6694,6.31941 5.99609,10.2129 1.32022,-3.8987 3.35723,-7.34901 5.97461,-10.22461 5.35266,-5.88074 13.13539,-9.35938 22.18947,-9.35938 17.25737,0 29.98828,12.63776 29.98828,30.46094 0,17.72889 -12.73147,30.45898 -29.89453,30.45898 -9.05246,0 -16.85908,-3.50268 -22.23633,-9.39258 -2.63433,-2.88547 -4.68681,-6.34417 -6.01563,-10.24414 -1.32604,3.89522 -3.3699,7.35103 -5.99414,10.23438 -5.36638,5.89626 -13.15374,9.40234 -22.16211,9.40234 -17.25736,0 -29.98828,-12.73009 -29.98828,-30.45898 0,-17.82318 12.63522,-30.46094 29.89257,-30.46094 z"
sodipodi:nodetypes="ssscssscssscssssscssss" />
<path
d="m 783.58585,284.6837 c -2.1037,1.2e-4 -3.80866,1.70684 -3.80664,3.81055 9e-5,2.10223 1.70442,3.80457 3.80664,3.80469 2.10221,-1.2e-4 3.8065,-1.70246 3.80664,-3.80469 0.002,-2.10371 -1.70294,-3.81043 -3.80664,-3.81055 z m 56.46484,0 c -2.10372,1.2e-4 -3.80862,1.70684 -3.80664,3.81055 1.3e-4,2.10223 1.70438,3.80457 3.80664,3.80469 2.10222,-1.2e-4 3.80652,-1.70246 3.80664,-3.80469 0.002,-2.10371 -1.70294,-3.81043 -3.80664,-3.81055 z"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#e880a8;fill-opacity:1;stroke-width:7.85855"
id="path28" />
</g>
<path <path
id="path22" id="path7"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#483737;fill-opacity:1;stroke-width:9.80478" style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#ff5fa2;fill-opacity:1;stroke-width:9.80478"
d="m 1464.5026,5 c -28.5907,0 -49.6505,20.826486 -49.6505,49.299551 0,28.473073 21.1791,49.297109 49.7699,49.297109 14.0881,0 26.3458,-5.056266 35.1585,-13.592646 8.8417,8.53638 21.1312,13.592646 35.2194,13.592646 28.5909,0 49.6506,-20.824036 49.6506,-49.297109 C 1584.6505,25.826486 1563.4739,5 1534.8832,5 1520.795,5 1508.5347,10.056275 1499.722,18.592651 1490.8804,10.056273 1478.5908,5 1464.5026,5 Z m 0,11.294718 c 11.3038,0 21.0508,4.346057 27.7603,11.69192 3.2744,3.584891 5.8258,7.884457 7.4811,12.742197 1.6471,-4.864244 4.1887,-9.169044 7.4542,-12.756817 6.6784,-7.337146 16.3885,-11.6773 27.685,-11.6773 21.5312,0 37.415,15.767597 37.415,38.004833 0,22.119593 -15.8844,38.002393 -37.2982,38.002393 -11.2943,0 -21.0343,-4.37015 -27.7432,-11.71873 -3.2869,-3.60007 -5.8476,-7.915358 -7.5056,-12.781188 -1.6544,4.859897 -4.2044,9.171568 -7.4786,12.769018 -6.6954,7.35651 -16.4112,11.7309 -27.6506,11.7309 -21.5314,0 -37.4152,-15.88281 -37.4152,-38.002393 0,-22.237236 15.7644,-38.004833 37.2958,-38.004833 z" d="m 1274.7513,120.97717 c -28.5907,0 -49.6505,20.82648 -49.6505,49.29955 0,28.47307 21.1791,49.29711 49.7699,49.29711 14.0881,0 26.3458,-5.05627 35.1585,-13.59265 8.8417,8.53638 21.1312,13.59265 35.2194,13.59265 28.5909,0 49.6506,-20.82404 49.6506,-49.29711 0,-28.47307 -21.1766,-49.29955 -49.7673,-49.29955 -14.0882,0 -26.3485,5.05627 -35.1612,13.59265 -8.8416,-8.53638 -21.1312,-13.59265 -35.2194,-13.59265 z m 0,11.29472 c 11.3038,0 21.0508,4.34605 27.7603,11.69192 3.2744,3.58489 5.8258,7.88445 7.4811,12.74219 1.6471,-4.86424 4.1887,-9.16904 7.4542,-12.75681 6.6784,-7.33715 16.3885,-11.6773 27.685,-11.6773 21.5312,0 37.415,15.76759 37.415,38.00483 0,22.11959 -15.8844,38.00239 -37.2982,38.00239 -11.2943,0 -21.0343,-4.37015 -27.7432,-11.71873 -3.2869,-3.60007 -5.8476,-7.91536 -7.5056,-12.78119 -1.6544,4.8599 -4.2044,9.17157 -7.4786,12.76902 -6.6954,7.35651 -16.4112,11.7309 -27.6506,11.7309 -21.5314,0 -37.4152,-15.88281 -37.4152,-38.00239 0,-22.23724 15.7644,-38.00483 37.2958,-38.00483 z"
sodipodi:nodetypes="ssscssscssscssssscssss" /> sodipodi:nodetypes="ssscssscssscssssscssss" />
<path <path
d="m 1463.5643,67.636337 c -2.6247,1.49e-4 -4.7519,2.129552 -4.7493,4.754267 10e-5,2.62286 2.1265,4.74679 4.7493,4.74695 2.623,-1.6e-4 4.7491,-2.12409 4.7495,-4.74695 0,-2.624715 -2.1248,-4.754118 -4.7495,-4.754267 z m 70.4489,0 c -2.6247,1.49e-4 -4.7518,2.129552 -4.7495,4.754267 2e-4,2.62286 2.1265,4.74679 4.7495,4.74695 2.6228,-1.6e-4 4.7492,-2.12409 4.7493,-4.74695 0,-2.624715 -2.1246,-4.754118 -4.7493,-4.754267 z" id="path8"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#e3bea2;fill-opacity:1;stroke-width:9.80478"
d="m 1464.7513,120.97717 c -28.5907,0 -49.6505,20.82648 -49.6505,49.29955 0,28.47307 21.1791,49.29711 49.7699,49.29711 14.0881,0 26.3458,-5.05627 35.1585,-13.59265 8.8417,8.53638 21.1312,13.59265 35.2194,13.59265 28.5909,0 49.6506,-20.82404 49.6506,-49.29711 0,-28.47307 -21.1766,-49.29955 -49.7673,-49.29955 -14.0882,0 -26.3485,5.05627 -35.1612,13.59265 -8.8416,-8.53638 -21.1312,-13.59265 -35.2194,-13.59265 z m 0,11.29472 c 11.3038,0 21.0508,4.34605 27.7603,11.69192 3.2744,3.58489 5.8258,7.88445 7.4811,12.74219 1.6471,-4.86424 4.1887,-9.16904 7.4542,-12.75681 6.6784,-7.33715 16.3885,-11.6773 27.685,-11.6773 21.5312,0 37.415,15.76759 37.415,38.00483 0,22.11959 -15.8844,38.00239 -37.2982,38.00239 -11.2943,0 -21.0343,-4.37015 -27.7432,-11.71873 -3.2869,-3.60007 -5.8476,-7.91536 -7.5056,-12.78119 -1.6544,4.8599 -4.2044,9.17157 -7.4786,12.76902 -6.6954,7.35651 -16.4112,11.7309 -27.6506,11.7309 -21.5314,0 -37.4152,-15.88281 -37.4152,-38.00239 0,-22.23724 15.7644,-38.00483 37.2958,-38.00483 z"
sodipodi:nodetypes="ssscssscssscssssscssss" />
<path
d="m 1273.813,183.6135 c -2.6247,1.5e-4 -4.7519,2.12956 -4.7493,4.75427 10e-5,2.62286 2.1265,4.74679 4.7493,4.74695 2.623,-1.6e-4 4.7491,-2.12409 4.7495,-4.74695 0,-2.62471 -2.1248,-4.75412 -4.7495,-4.75427 z m 70.4489,0 c -2.6247,1.5e-4 -4.7518,2.12956 -4.7495,4.75427 2e-4,2.62286 2.1265,4.74679 4.7495,4.74695 2.6228,-1.6e-4 4.7492,-2.12409 4.7493,-4.74695 0,-2.62471 -2.1246,-4.75412 -4.7493,-4.75427 z"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#8a6f91;fill-opacity:1;stroke-width:9.80478" style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#8a6f91;fill-opacity:1;stroke-width:9.80478"
id="path1-8" /> id="path1-62" />
<path
d="m 1463.813,183.61351 c -2.6247,1.4e-4 -4.7519,2.12955 -4.7493,4.75426 10e-5,2.62286 2.1265,4.74679 4.7493,4.74695 2.623,-1.6e-4 4.7491,-2.12409 4.7495,-4.74695 0,-2.62471 -2.1248,-4.75412 -4.7495,-4.75426 z m 70.4489,0 c -2.6247,1.4e-4 -4.7518,2.12955 -4.7495,4.75426 2e-4,2.62286 2.1265,4.74679 4.7495,4.74695 2.6228,-1.6e-4 4.7492,-2.12409 4.7493,-4.74695 0,-2.62471 -2.1246,-4.75412 -4.7493,-4.75426 z"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#e880a8;fill-opacity:1;stroke-width:9.80478"
id="path1-7" />
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="400"
height="400"
viewBox="0 0 400 399.99999"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="GOONDEX_square.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
inkscape:zoom="1.216"
inkscape:cx="423.51974"
inkscape:cy="245.06579"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:page
x="0"
y="0"
width="400"
height="400"
id="page1"
margin="0"
bleed="0" />
<inkscape:page
x="410"
y="0"
width="400"
height="400"
id="page2"
margin="0"
bleed="0"
inkscape:export-filename="Page 2.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#ffffff"
id="rect1"
width="400"
height="400"
x="0"
y="0" />
<rect
style="fill:#ff5fa2;fill-opacity:1"
id="rect2"
width="400"
height="400"
x="410"
y="4.9999999e-06" />
<path
id="path13"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#ff5fa2;fill-opacity:1;stroke-width:20.7876"
d="M 125.26694,95.479625 C 64.64998,95.479625 20,139.63511 20,200.00258 c 0,60.3675 44.90316,104.51781 105.52008,104.51781 29.86915,0 55.85772,-10.72014 74.54191,-28.8186 18.74564,18.09846 44.80196,28.8186 74.67107,28.8186 C 335.35004,304.52039 380,260.37008 380,200.00258 380,139.63511 335.10205,95.479625 274.48508,95.479625 c -29.86913,0 -55.86288,10.720115 -74.54707,28.818605 -18.74567,-18.09849 -44.80196,-28.818605 -74.67107,-28.818605 z m 0,23.946605 c 23.96579,0 44.63124,9.21433 58.85648,24.78875 6.94213,7.60055 12.35163,16.71632 15.86107,27.01549 3.49229,-10.31296 8.88066,-19.43983 15.80425,-27.04648 14.15904,-15.55593 34.74619,-24.75776 58.69634,-24.75776 45.6498,0 79.32606,33.42985 79.32606,80.57635 0,46.89709 -33.67774,80.57117 -79.07808,80.57117 -23.94588,0 -44.59623,-9.2654 -58.82031,-24.84558 -6.96843,-7.63274 -12.39772,-16.78182 -15.91276,-27.09816 -3.50769,10.30378 -8.91419,19.4452 -15.85591,27.07234 -14.19534,15.597 -34.79474,24.8714 -58.624,24.8714 -45.64978,0 -79.326065,-33.67408 -79.326065,-80.57117 0,-47.1465 33.423135,-80.57635 79.072925,-80.57635 z m -1.9891,108.85247 c -5.56477,3.2e-4 -10.0748,4.515 -10.06945,10.07979 2.3e-4,5.56091 4.50858,10.064 10.06945,10.06432 5.56085,-3.2e-4 10.06909,-4.50341 10.06946,-10.06432 0.006,-5.56479 -4.50468,-10.07947 -10.06946,-10.07979 z m 149.36279,0 c -5.56483,3.2e-4 -10.07469,4.515 -10.06946,10.07979 3.5e-4,5.56091 4.50849,10.064 10.06946,10.06432 5.56087,-3.2e-4 10.06915,-4.50341 10.06947,-10.06432 0.006,-5.56479 -4.50468,-10.07947 -10.06947,-10.07979 z" />
<path
id="path2"
style="font-size:86.3973px;font-family:'Gmarket Sans';-inkscape-font-specification:'Gmarket Sans, Normal';fill:#ffffff;fill-opacity:1;stroke-width:20.7876"
d="M 535.26694,95.479622 C 474.64998,95.479622 430,139.63511 430,200.00258 c 0,60.3675 44.90316,104.51781 105.52008,104.51781 29.86915,0 55.85772,-10.72014 74.54191,-28.8186 18.74564,18.09846 44.80196,28.8186 74.67107,28.8186 C 745.35004,304.52039 790,260.37008 790,200.00258 790,139.63511 745.10205,95.479622 684.48508,95.479622 c -29.86913,0 -55.86288,10.720118 -74.54707,28.818608 -18.74567,-18.09849 -44.80196,-28.818608 -74.67107,-28.818608 z m 0,23.946608 c 23.96579,0 44.63124,9.21433 58.85648,24.78875 6.94213,7.60055 12.35163,16.71632 15.86107,27.01549 3.49229,-10.31296 8.88066,-19.43983 15.80425,-27.04648 14.15904,-15.55593 34.74619,-24.75776 58.69634,-24.75776 45.6498,0 79.32606,33.42985 79.32606,80.57635 0,46.89709 -33.67774,80.57117 -79.07808,80.57117 -23.94588,0 -44.59623,-9.2654 -58.82031,-24.84558 -6.96843,-7.63274 -12.39772,-16.78182 -15.91276,-27.09816 -3.50769,10.30378 -8.91419,19.4452 -15.85591,27.07234 -14.19534,15.597 -34.79474,24.8714 -58.624,24.8714 -45.64978,0 -79.32606,-33.67408 -79.32606,-80.57117 0,-47.1465 33.42313,-80.57635 79.07292,-80.57635 z m -1.9891,108.85247 c -5.56477,3.2e-4 -10.0748,4.515 -10.06945,10.07979 2.3e-4,5.56091 4.50858,10.064 10.06945,10.06432 5.56085,-3.2e-4 10.06909,-4.50341 10.06946,-10.06432 0.006,-5.56479 -4.50468,-10.07947 -10.06946,-10.07979 z m 149.36279,0 c -5.56483,3.2e-4 -10.07469,4.515 -10.06946,10.07979 3.5e-4,5.56091 4.50849,10.064 10.06946,10.06432 5.56087,-3.2e-4 10.06915,-4.50341 10.06947,-10.06432 0.006,-5.56479 -4.50468,-10.07947 -10.06947,-10.07979 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -5,16 +5,47 @@ function openModal(modalId) {
const modal = document.getElementById(modalId); const modal = document.getElementById(modalId);
if (modal) { if (modal) {
modal.classList.add('active'); modal.classList.add('active');
}
} }
} }
function closeModal(modalId) { // ============================================================================
const modal = document.getElementById(modalId); // Logo Animation for Loading Screens
if (modal) { // ============================================================================
modal.classList.remove('active');
let logoAnimator = null;
function startLogoAnimation() {
// Find logo in loader or main content
const logoElement = document.querySelector('#global-loader .logo img,
#global-loader .logo svg,
.logo img, .logo svg');
if (logoElement && !logoAnimator) {
// Add CSS if not already loaded
if (!document.querySelector('#logo-animation-css')) {
const css = document.createElement('link');
css.id = 'logo-animation-css';
css.rel = 'stylesheet';
css.href = '/static/css/logo-animation.css';
document.head.appendChild(css);
}
// Initialize animator
logoAnimator = new LogoAnimator();
logoAnimator.init(logoElement);
logoAnimator.startBounce();
} }
} }
function stopLogoAnimation() {
if (logoAnimator) {
logoAnimator.stopBounce();
logoAnimator = null;
}
}
}
// Import functions // Import functions
// Global Search // Global Search
let searchTimeout; let searchTimeout;
@ -97,151 +128,62 @@ function displayGlobalSearchResults(data) {
// Bulk Import Functions // Bulk Import Functions
async function bulkImportAll() { async function bulkImportAll() {
if (!confirm('This will import ALL data from TPDB. This may take several hours. Continue?')) { showLoader('Importing everything...');
return; startJobProgress('Full library import');
}
setImportStatus('import-all', 'Importing all data from TPDB... This may take a while.', false);
try { try {
const response = await fetch('/api/import/all', { await importWithProgress('/api/import/all-performers/progress', 'Performers');
method: 'POST' await importWithProgress('/api/import/all-studios/progress', 'Studios');
}); await importWithProgress('/api/import/all-scenes/progress', 'Scenes');
setImportStatus('import-all', 'Import complete', true);
const result = await response.json(); setTimeout(() => location.reload(), 1500);
} catch (err) {
if (result.success) { setImportStatus('import-all', `Import error: ${err.message}`, false);
let message = result.message + '\n\n'; } finally {
if (result.data) { stopJobProgress();
result.data.forEach(r => { hideLoader();
message += `${r.EntityType}: ${r.Imported}/${r.Total} imported, ${r.Failed} failed\n`;
});
}
setImportStatus('import-all', message, true);
setTimeout(() => {
closeModal('import-all-modal');
location.reload();
}, 3000);
} else {
setImportStatus('import-all', result.message, false);
}
} catch (error) {
setImportStatus('import-all', 'Error: ' + error.message, false);
} }
} }
async function bulkImportPerformers() { async function bulkImportPerformers() {
if (!confirm('This will import ALL performers from TPDB. Continue?')) { showLoader('Importing performers...');
return; startJobProgress('Importing performers');
try {
await importWithProgress('/api/import/all-performers/progress', 'Performers');
setTimeout(() => location.reload(), 1000);
} catch (err) {
setImportStatus('performer', `Error: ${err.message}`, false);
} finally {
stopJobProgress();
hideLoader();
} }
// Show progress modal
showProgressModal('performers');
// Connect to SSE endpoint
const eventSource = new EventSource('/api/import/all-performers/progress');
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.error) {
updateProgress('performers', 0, 0, data.error, true);
eventSource.close();
return;
}
if (data.complete) {
updateProgress('performers', 100, 100, `Complete! Imported ${data.result.Imported}/${data.result.Total} performers`, false);
eventSource.close();
setTimeout(() => {
closeProgressModal();
location.reload();
}, 2000);
} else {
updateProgress('performers', data.current, data.total, data.message, false);
}
};
eventSource.onerror = function() {
updateProgress('performers', 0, 0, 'Connection error', true);
eventSource.close();
};
} }
async function bulkImportStudios() { async function bulkImportStudios() {
if (!confirm('This will import ALL studios from TPDB. Continue?')) { showLoader('Importing studios...');
return; startJobProgress('Importing studios');
try {
await importWithProgress('/api/import/all-studios/progress', 'Studios');
setTimeout(() => location.reload(), 1000);
} catch (err) {
setImportStatus('studio', `Error: ${err.message}`, false);
} finally {
stopJobProgress();
hideLoader();
} }
// Show progress modal
showProgressModal('studios');
// Connect to SSE endpoint
const eventSource = new EventSource('/api/import/all-studios/progress');
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.error) {
updateProgress('studios', 0, 0, data.error, true);
eventSource.close();
return;
}
if (data.complete) {
updateProgress('studios', 100, 100, `Complete! Imported ${data.result.Imported}/${data.result.Total} studios`, false);
eventSource.close();
setTimeout(() => {
closeProgressModal();
location.reload();
}, 2000);
} else {
updateProgress('studios', data.current, data.total, data.message, false);
}
};
eventSource.onerror = function() {
updateProgress('studios', 0, 0, 'Connection error', true);
eventSource.close();
};
} }
async function bulkImportScenes() { async function bulkImportScenes() {
if (!confirm('This will import ALL scenes from TPDB. Continue?')) { showLoader('Importing scenes...');
return; startJobProgress('Importing scenes');
try {
await importWithProgress('/api/import/all-scenes/progress', 'Scenes');
setTimeout(() => location.reload(), 1000);
} catch (err) {
setImportStatus('scene', `Error: ${err.message}`, false);
} finally {
stopJobProgress();
hideLoader();
} }
// Show progress modal
showProgressModal('scenes');
// Connect to SSE endpoint
const eventSource = new EventSource('/api/import/all-scenes/progress');
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.error) {
updateProgress('scenes', 0, 0, data.error, true);
eventSource.close();
return;
}
if (data.complete) {
updateProgress('scenes', 100, 100, `Complete! Imported ${data.result.Imported}/${data.result.Total} scenes`, false);
eventSource.close();
setTimeout(() => {
closeProgressModal();
location.reload();
}, 2000);
} else {
updateProgress('scenes', data.current, data.total, data.message, false);
}
};
eventSource.onerror = function() {
updateProgress('scenes', 0, 0, 'Connection error', true);
eventSource.close();
};
} }
function bulkImportMovies() { function bulkImportMovies() {
@ -291,6 +233,7 @@ async function aeImportPerformerByName() {
const name = prompt('Import performer by name (Adult Empire):'); const name = prompt('Import performer by name (Adult Empire):');
if (!name) return; if (!name) return;
setAEStatus(`Searching Adult Empire for "${name}"...`); setAEStatus(`Searching Adult Empire for "${name}"...`);
showLoader(`Importing performer "${name}" from Adult Empire...`);
try { try {
const res = await fetch('/api/ae/import/performer', { const res = await fetch('/api/ae/import/performer', {
method: 'POST', method: 'POST',
@ -306,6 +249,8 @@ async function aeImportPerformerByName() {
} }
} catch (err) { } catch (err) {
setAEStatus(`Error: ${err.message}`, true); setAEStatus(`Error: ${err.message}`, true);
} finally {
hideLoader();
} }
} }
@ -313,6 +258,7 @@ async function aeImportPerformerByURL() {
const url = prompt('Paste Adult Empire performer URL:'); const url = prompt('Paste Adult Empire performer URL:');
if (!url) return; if (!url) return;
setAEStatus('Importing performer from Adult Empire URL...'); setAEStatus('Importing performer from Adult Empire URL...');
showLoader('Importing performer from Adult Empire URL...');
try { try {
const res = await fetch('/api/ae/import/performer-by-url', { const res = await fetch('/api/ae/import/performer-by-url', {
method: 'POST', method: 'POST',
@ -328,6 +274,8 @@ async function aeImportPerformerByURL() {
} }
} catch (err) { } catch (err) {
setAEStatus(`Error: ${err.message}`, true); setAEStatus(`Error: ${err.message}`, true);
} finally {
hideLoader();
} }
} }
@ -335,6 +283,7 @@ async function aeImportSceneByName() {
const title = prompt('Import scene by title (Adult Empire):'); const title = prompt('Import scene by title (Adult Empire):');
if (!title) return; if (!title) return;
setAEStatus(`Searching Adult Empire for "${title}"...`); setAEStatus(`Searching Adult Empire for "${title}"...`);
showLoader(`Importing scene "${title}" from Adult Empire...`);
try { try {
const res = await fetch('/api/ae/import/scene', { const res = await fetch('/api/ae/import/scene', {
method: 'POST', method: 'POST',
@ -350,6 +299,8 @@ async function aeImportSceneByName() {
} }
} catch (err) { } catch (err) {
setAEStatus(`Error: ${err.message}`, true); setAEStatus(`Error: ${err.message}`, true);
} finally {
hideLoader();
} }
} }
@ -357,6 +308,7 @@ async function aeImportSceneByURL() {
const url = prompt('Paste Adult Empire scene URL:'); const url = prompt('Paste Adult Empire scene URL:');
if (!url) return; if (!url) return;
setAEStatus('Importing scene from Adult Empire URL...'); setAEStatus('Importing scene from Adult Empire URL...');
showLoader('Importing scene from Adult Empire URL...');
try { try {
const res = await fetch('/api/ae/import/scene-by-url', { const res = await fetch('/api/ae/import/scene-by-url', {
method: 'POST', method: 'POST',
@ -372,6 +324,8 @@ async function aeImportSceneByURL() {
} }
} catch (err) { } catch (err) {
setAEStatus(`Error: ${err.message}`, true); setAEStatus(`Error: ${err.message}`, true);
} finally {
hideLoader();
} }
} }
@ -539,6 +493,98 @@ function setImportStatus(type, message, success) {
} }
// Close modals when clicking outside // Close modals when clicking outside
// Global loader helpers
function showLoader(msg) {
const overlay = document.getElementById('global-loader');
const text = document.getElementById('global-loader-text');
if (overlay) {
overlay.style.display = 'flex';
// Start logo animation when loader shows
startLogoAnimation();
}
if (text && msg) {
text.textContent = msg;
}
}
function hideLoader() {
const overlay = document.getElementById('global-loader');
if (overlay) {
overlay.style.display = 'none';
// Stop logo animation when loader hides
stopLogoAnimation();
}
}
// Unified SSE import helper with progress bar
function importWithProgress(url, label) {
return new Promise((resolve, reject) => {
const eventSource = new EventSource(url);
startJobProgress(label);
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.error) {
updateJobProgress(0, 0, data.error, true);
eventSource.close();
reject(new Error(data.error));
return;
}
if (data.complete) {
updateJobProgress(data.result.Imported, data.result.Total, `${label} complete (${data.result.Imported}/${data.result.Total})`, false);
eventSource.close();
resolve(data.result);
return;
}
updateJobProgress(data.current, data.total, data.message, false);
};
eventSource.onerror = function() {
updateJobProgress(0, 0, `${label} connection error`, true);
eventSource.close();
reject(new Error('Connection error'));
};
});
}
function startJobProgress(label) {
const container = document.getElementById('job-progress');
const lbl = document.getElementById('job-progress-label');
const msg = document.getElementById('job-progress-message');
const fill = document.getElementById('job-progress-fill');
const count = document.getElementById('job-progress-count');
if (container && lbl && msg && fill && count) {
container.style.display = 'block';
lbl.textContent = label || 'Working...';
msg.textContent = '';
count.textContent = '';
fill.style.width = '0%';
}
}
function updateJobProgress(current, total, message, isError) {
const container = document.getElementById('job-progress');
const msg = document.getElementById('job-progress-message');
const fill = document.getElementById('job-progress-fill');
const count = document.getElementById('job-progress-count');
if (container && fill && msg && count) {
const percent = total > 0 ? Math.min(100, (current / total) * 100) : 0;
fill.style.width = `${percent}%`;
count.textContent = total > 0 ? `${current}/${total}` : '';
msg.textContent = message || '';
if (isError) {
fill.style.background = '#ff8a8a';
msg.style.color = '#ff8a8a';
} else {
fill.style.background = 'linear-gradient(135deg, var(--color-brand) 0%, var(--color-keypoint) 100%)';
msg.style.color = 'var(--color-text-secondary)';
}
}
}
function stopJobProgress() {
const container = document.getElementById('job-progress');
if (container) container.style.display = 'none';
}
window.onclick = function(event) { window.onclick = function(event) {
if (event.target.classList.contains('modal')) { if (event.target.classList.contains('modal')) {
event.target.classList.remove('active'); event.target.classList.remove('active');

View File

@ -0,0 +1,103 @@
class LogoAnimator {
constructor() {
this.isAnimating = false;
this.logoElement = null;
}
init(svgElement) {
this.logoElement = svgElement;
this.identifyParts();
}
identifyParts() {
if (!this.logoElement) return;
const nipples = [];
const breasts = [];
const breastCandidates = [
this.logoElement.querySelector('#breast-left'),
this.logoElement.querySelector('#breast-right')
].filter(Boolean);
const nippleCandidates = [
this.logoElement.querySelector('#nipple-left'),
this.logoElement.querySelector('#nipple-right')
].filter(Boolean);
breasts.push(...breastCandidates);
nipples.push(...nippleCandidates);
if (nipples.length < 2) {
const circ = Array.from(this.logoElement.querySelectorAll('circle, ellipse'));
while (nipples.length < 2 && circ.length) nipples.push(circ.shift());
}
if (breasts.length < 2) {
const shapes = Array.from(this.logoElement.querySelectorAll('path, polygon, rect'));
while (breasts.length < 2 && shapes.length) breasts.push(shapes.shift());
}
if (breasts.length === 0) breasts.push(this.logoElement);
if (breasts.length === 1) breasts.push(this.logoElement);
if (breasts[0]) breasts[0].classList.add('breast-left');
if (breasts[1]) breasts[1].classList.add('breast-right');
if (nipples.length === 0) nipples.push(breasts[0], breasts[1]);
nipples.slice(0, 2).forEach((el, idx) => el && el.classList.add(idx === 0 ? 'nipple-left' : 'nipple-right'));
}
startBounce() {
if (!this.logoElement || this.isAnimating) return;
this.logoElement.classList.add('goondex-logo-animated');
this.isAnimating = true;
}
stopBounce() {
if (!this.logoElement) return;
this.logoElement.classList.remove('goondex-logo-animated');
this.isAnimating = false;
}
}
async function loadSVG(urls, targetId) {
const target = document.getElementById(targetId);
if (!target) return null;
for (const url of urls) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error('fetch failed');
const svgText = await res.text();
target.innerHTML = svgText;
const svg = target.querySelector('svg');
return svg;
} catch (e) {
continue;
}
}
// Fallback to img if all fetches fail
target.innerHTML = `<img src="${urls[0]}" alt="Goondex Logo" width="100%" height="100%">`;
return null;
}
(async function initLogoAnim() {
const logoURLs = [
"/static/img/logo/GOONDEX_Titty.svg",
"http://localhost:8788/static/img/logo/GOONDEX_Titty.svg",
];
const staticSvg = await loadSVG(logoURLs, 'static-logo');
const animatedSvg = await loadSVG(logoURLs, 'animated-logo');
const loaderSvg = await loadSVG(logoURLs, 'loader-logo');
window.goondexLogoAnim = { animator: null, loaderAnimator: null };
if (animatedSvg) {
const animator = new LogoAnimator();
animator.init(animatedSvg);
animator.startBounce();
window.goondexLogoAnim.animator = animator;
}
if (loaderSvg) {
const l = new LogoAnimator();
l.init(loaderSvg);
window.goondexLogoAnim.loaderAnimator = l;
}
})();

View File

@ -0,0 +1,58 @@
// Minimal logo animation controller
class LogoAnimator {
constructor() {
this.isAnimating = false;
this.logoElement = null;
}
// Initialize with SVG element
init(svgElement) {
this.logoElement = svgElement;
this.identifyNipples();
}
// Identify nipple elements by their circular paths
identifyNipples() {
if (!this.logoElement) return;
const paths = this.logoElement.querySelectorAll('path');
let nippleIndex = 0;
paths.forEach((path) => {
const d = path.getAttribute('d');
// Look for the specific circular nipple paths in the GOONDEX_Titty.svg
if (d && d.includes('1463.5643,67.636337')) {
path.classList.add('nipple-left');
nippleIndex++;
} else if (d && d.includes('70.4489,0') && nippleIndex === 1) {
path.classList.add('nipple-right');
nippleIndex++;
}
});
}
// Start bouncing animation
startBounce() {
if (!this.logoElement || this.isAnimating) return;
this.logoElement.classList.add('goondex-logo-animated');
this.isAnimating = true;
}
// Stop animation
stopBounce() {
if (!this.logoElement) return;
this.logoElement.classList.remove('goondex-logo-animated');
this.isAnimating = false;
}
// Auto-start for loading screens
autoStart(duration = 3000) {
this.startBounce();
setTimeout(() => this.stopBounce(), duration);
}
}
// Export for use in loading screens
window.LogoAnimator = LogoAnimator;

View File

@ -2,203 +2,173 @@
<html lang="en"> <html lang="en">
<head> <head>
{{template "html-head" .}} {{template "html-head" .}}
<style>
/* ==== LUXURY SIDE PANELS (A1 Medium 240px) ==== */
body {
display: flex;
justify-content: center;
align-items: stretch;
min-height: 100vh;
overflow-x: hidden;
}
.side-panel {
width: 240px;
flex-shrink: 0;
background: #000;
border-right: 1px solid rgba(255, 79, 163, 0.2);
border-left: 1px solid rgba(255, 79, 163, 0.2);
display: flex;
flex-direction: column;
overflow: hidden;
position: sticky;
top: 0;
height: 100vh;
}
.side-panel.right {
border-right: none;
}
.side-panel img {
width: 100%;
height: auto;
display: block;
object-fit: cover;
opacity: 0.85;
transition: opacity 0.3s ease;
}
.side-panel img:hover {
opacity: 1;
}
/* Main site content */
.main-wrapper {
flex: 1;
overflow-y: auto;
max-width: 1400px;
}
/* Ensure navbar stays inside main-wrapper */
nav.navbar {
position: sticky;
top: 0;
z-index: 50;
}
/* Search results styling override to match new layout */
#global-search-results {
max-width: 100%;
}
/* Hide side panels on mobile */
@media (max-width: 900px) {
.side-panel {
display: none;
}
}
</style>
</head> </head>
<body> <body class="app-shell">
{{template "navbar" .}}
<!-- LEFT LUXURY SIDE PANEL --> <div class="app-body container-fluid px-3 px-lg-4 px-xxl-5">
<div class="side-panel left"> <main class="content-stack">
<img src="/static/img/sidebar/preview1.jpg" alt="">
<img src="/static/img/sidebar/preview2.jpg" alt="">
<img src="/static/img/sidebar/preview3.jpg" alt="">
</div>
<!-- MAIN CONTENT WRAPPER -->
<div class="main-wrapper">
<!-- NAVIGATION -->
{{template "navbar" .}}
<main class="container">
<!-- HERO -->
<section class="hero-section"> <section class="hero-section">
<div class="section-kicker">Control center</div>
<h1 class="hero-title">Welcome to Goondex</h1> <h1 class="hero-title">Welcome to Goondex</h1>
<p class="hero-subtitle">TPDB bulk imports with Adult Empire enrichment</p> <p class="hero-subtitle">Full-library sync with seamless enrichment</p>
<div class="hero-actions"> <div class="hero-actions">
<button class="btn" onclick="bulkImportAll()"> <button type="button" class="btn btn-light-primary" onclick="bulkImportAll()">
TPDB Bulk Import Full Import
<div class="hoverEffect"><div></div></div> <div class="hoverEffect"><div></div></div>
</button> </button>
<button class="btn-secondary" onclick="syncAll()"> <button type="button" class="btn-secondary" onclick="syncAll()">
Sync Data Sync Library
<div class="hoverEffect"><div></div></div> <div class="hoverEffect"><div></div></div>
</button> </button>
</div> </div>
</section> </section>
<!-- SEARCH --> <div class="row g-4 align-items-stretch">
<section class="search-section" style="margin-bottom: 2.5rem;"> <div class="col-12 col-xl-8">
<input type="text" id="global-search" class="input" <section class="surface-panel content-stack h-100">
placeholder="Search performers, studios, scenes, or tags..."> <div class="section-header">
<div id="global-search-results" class="search-results"></div> <div>
</section> <div class="section-kicker">Search everything</div>
<div class="section-title">Global search</div>
</div>
<div class="section-hint">Performers, studios, scenes, tags</div>
</div>
<!-- STATS --> <section class="search-section mb-0">
<section class="stats-grid"> <input type="text" id="global-search" class="input"
<!-- Performers --> placeholder="Search performers, studios, scenes, or tags...">
<div class="stat-card"> <div id="global-search-results" class="search-results"></div>
<div class="stat-icon">👤</div> </section>
<div class="stat-content"> </section>
<div class="stat-value">{{.PerformerCount}}</div>
<div class="stat-label">Performers</div>
</div>
<div class="stat-actions">
<a href="/performers" class="stat-link">View all →</a>
<button class="btn-small" onclick="aeImportPerformerByName()">
Quick import
<div class="hoverEffect"><div></div></div>
</button>
</div>
</div> </div>
<!-- Studios --> <div class="col-12 col-xl-4">
<div class="stat-card"> <section class="surface-panel content-stack h-100">
<div class="stat-icon">🏢</div> <div class="section-header">
<div class="stat-content"> <div>
<div class="stat-value">{{.StudioCount}}</div> <div class="section-kicker">Quick commands</div>
<div class="stat-label">Studios</div> <div class="section-title">One-click control</div>
</div> </div>
<div class="stat-actions"> </div>
<a href="/studios" class="stat-link">View all →</a>
<div class="d-grid gap-2">
<button type="button" class="btn btn-light-primary w-100" onclick="bulkImportAll()">
Full Import
<div class="hoverEffect"><div></div></div>
</button>
<button type="button" class="btn-secondary w-100" onclick="syncAll()">
Sync Library
<div class="hoverEffect"><div></div></div>
</button>
</div>
<p class="section-hint mb-0">Safe defaults with progress feedback.</p>
</section>
</div>
</div>
<section class="surface-panel">
<div class="section-header">
<div>
<div class="section-kicker">Library health</div>
<div class="section-title">Live snapshot</div>
</div> </div>
<div class="section-hint">Counts update as imports finish</div>
</div> </div>
<!-- Scenes --> <div class="stats-grid">
<div class="stat-card"> <!-- Performers -->
<div class="stat-icon">🎬</div> <div class="stat-card">
<div class="stat-content"> <div class="stat-icon">👤</div>
<div class="stat-value">{{.SceneCount}}</div> <div class="stat-content">
<div class="stat-label">Scenes</div> <div class="stat-value">{{.PerformerCount}}</div>
<div class="stat-label">Performers</div>
</div>
<div class="stat-actions">
<a href="/performers" class="stat-link">View all →</a>
<button class="btn-small" onclick="bulkImportPerformers()">
Import all
<div class="hoverEffect"><div></div></div>
</button>
</div>
</div> </div>
<div class="stat-actions">
<a href="/scenes" class="stat-link">View all →</a>
<button class="btn-small" onclick="aeImportSceneByName()">
Quick import
<div class="hoverEffect"><div></div></div>
</button>
</div>
</div>
<!-- Movies --> <!-- Studios -->
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon">🎞️</div> <div class="stat-icon">🏢</div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-value">{{.MovieCount}}</div> <div class="stat-value">{{.StudioCount}}</div>
<div class="stat-label">Movies</div> <div class="stat-label">Studios</div>
</div>
<div class="stat-actions">
<a href="/studios" class="stat-link">View all →</a>
</div>
</div> </div>
<div class="stat-actions">
<a href="/movies" class="stat-link">View all →</a> <!-- Scenes -->
<div class="stat-card">
<div class="stat-icon">🎬</div>
<div class="stat-content">
<div class="stat-value">{{.SceneCount}}</div>
<div class="stat-label">Scenes</div>
</div>
<div class="stat-actions">
<a href="/scenes" class="stat-link">View all →</a>
<button class="btn-small" onclick="aeImportSceneByName()">
Quick import
<div class="hoverEffect"><div></div></div>
</button>
</div>
</div>
<!-- Movies -->
<div class="stat-card">
<div class="stat-icon">🎞️</div>
<div class="stat-content">
<div class="stat-value">{{.MovieCount}}</div>
<div class="stat-label">Movies</div>
</div>
<div class="stat-actions">
<a href="/movies" class="stat-link">View all →</a>
</div>
</div> </div>
</div> </div>
</section> </section>
<!-- TPDB IMPORT/SYNC --> <section class="surface-panel content-stack">
<section class="import-section"> <div class="section-header">
<h3 id="ae-import">TPDB Import & Sync</h3> <div>
<p class="help-text"> <div class="section-kicker">Pipeline</div>
Run bulk imports from TPDB, then enrich with AE/StashDB. Keep it running to build a complete base. <div class="section-title">Library Import & Sync</div>
</div>
<div class="section-hint">Run a full import, then sync regularly.</div>
</div>
<p class="help-text mb-0">
Enrichment runs behind the scenes. Keep everything fresh with sync after imports.
</p> </p>
<div class="import-buttons"> <div class="import-buttons">
<button class="btn" onclick="bulkImportAll()"> <button type="button" class="btn" onclick="bulkImportAll()">
Import Everything (TPDB) Import Everything
<div class="hoverEffect"><div></div></div> <div class="hoverEffect"><div></div></div>
</button> </button>
<button class="btn-secondary" onclick="bulkImportPerformers()"> <button type="button" class="btn-secondary" onclick="bulkImportPerformers()">
Import All Performers Import Performers
<div class="hoverEffect"><div></div></div> <div class="hoverEffect"><div></div></div>
</button> </button>
<button class="btn-secondary" onclick="bulkImportStudios()"> <button type="button" class="btn-secondary" onclick="bulkImportStudios()">
Import All Studios Import Studios
<div class="hoverEffect"><div></div></div> <div class="hoverEffect"><div></div></div>
</button> </button>
<button class="btn-secondary" onclick="bulkImportScenes()"> <button type="button" class="btn-secondary" onclick="bulkImportScenes()">
Import All Scenes Import Scenes
<div class="hoverEffect"><div></div></div> <div class="hoverEffect"><div></div></div>
</button> </button>
<button class="btn-secondary" onclick="syncAll()"> <button type="button" class="btn-secondary" onclick="syncAll()">
Sync All Sync All
<div class="hoverEffect"><div></div></div> <div class="hoverEffect"><div></div></div>
</button> </button>
@ -208,12 +178,14 @@
<div id="sync-import-status" class="status-banner" style="margin-top: 0.75rem;"></div> <div id="sync-import-status" class="status-banner" style="margin-top: 0.75rem;"></div>
</section> </section>
<!-- AE IMPORT SECTION --> <section class="surface-panel content-stack">
<section class="import-section"> <div class="section-header">
<h3>Adult Empire Imports</h3> <div>
<p class="help-text"> <div class="section-kicker">Adult Empire</div>
Import directly from Adult Empire via the UI with built-in progress feedback. <div class="section-title">Direct imports</div>
</p> </div>
<div class="section-hint">Built-in progress feedback for manual pulls.</div>
</div>
<div class="import-buttons"> <div class="import-buttons">
<button class="btn-secondary" onclick="aeImportPerformerByName()"> <button class="btn-secondary" onclick="aeImportPerformerByName()">
@ -240,20 +212,9 @@
<div id="ae-status" class="status-banner"></div> <div id="ae-status" class="status-banner"></div>
</section> </section>
</main> </main>
</div> </div>
<!-- RIGHT LUXURY SIDE PANEL -->
<div class="side-panel right">
<img src="/static/img/sidebar/preview4.jpg" alt="">
<img src="/static/img/sidebar/preview5.jpg" alt="">
<img src="/static/img/sidebar/preview6.jpg" alt="">
</div>
<!-- EXISTING MODALS (unchanged, full code integrity kept) -->
{{/* Your modals remain exactly as before */}}
{{template "html-scripts" .}} {{template "html-scripts" .}}
</body> </body>
</html> </html>

View File

@ -23,7 +23,7 @@
{{define "navbar"}} {{define "navbar"}}
<nav class="navbar navbar-expand-lg navbar-dark"> <nav class="navbar navbar-expand-lg navbar-dark">
<div class="container nav-inner"> <div class="container-fluid nav-inner px-3 px-lg-4 px-xxl-5">
<a class="navbar-brand d-flex align-items-center" href="/"> <a class="navbar-brand d-flex align-items-center" href="/">
<img src="/static/img/logo/Goondex_LOGO.png" class="logo-img" alt="Goondex logo"> <img src="/static/img/logo/Goondex_LOGO.png" class="logo-img" alt="Goondex logo">
</a> </a>
@ -57,4 +57,23 @@
</div> </div>
</div> </div>
</nav> </nav>
<div id="global-loader" class="global-loader" style="display:none;">
<div class="loader-content">
<div class="logo">
<img src="/static/img/logo/GOONDEX_Titty.svg" alt="Goondex" width="90" height="55">
</div>
<div class="spinner"></div>
<div id="global-loader-text">Working...</div>
</div>
</div>
<div id="job-progress" class="job-progress" style="display:none;">
<div class="job-progress-header">
<span id="job-progress-label">Importing...</span>
<span id="job-progress-count"></span>
</div>
<div class="job-progress-bar">
<div class="job-progress-fill" id="job-progress-fill" style="width:0%"></div>
</div>
<div class="job-progress-message" id="job-progress-message"></div>
</div>
{{end}} {{end}}

View File

@ -3,10 +3,11 @@
<head> <head>
{{template "html-head" .}} {{template "html-head" .}}
</head> </head>
<body> <body class="app-shell">
{{template "navbar" .}} {{template "navbar" .}}
<main class="container"> <div class="app-body container-fluid px-3 px-lg-4 px-xxl-5">
<main class="container">
<div class="detail-header"> <div class="detail-header">
<div class="detail-image"> <div class="detail-image">
{{if .Movie.ImageURL}} {{if .Movie.ImageURL}}
@ -98,7 +99,8 @@
</div> </div>
</section> </section>
{{end}} {{end}}
</main> </main>
</div>
{{template "html-scripts" .}} {{template "html-scripts" .}}
</body> </body>
</html> </html>

View File

@ -3,10 +3,11 @@
<head> <head>
{{template "html-head" .}} {{template "html-head" .}}
</head> </head>
<body> <body class="app-shell">
{{template "navbar" .}} {{template "navbar" .}}
<main class="container"> <div class="app-body container-fluid px-3 px-lg-4 px-xxl-5">
<main class="container">
<div class="page-header"> <div class="page-header">
<h2>Movies</h2> <h2>Movies</h2>
<form class="search-form" action="/movies" method="get"> <form class="search-form" action="/movies" method="get">
@ -60,7 +61,8 @@
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
</main> </main>
</div>
{{template "html-scripts" .}} {{template "html-scripts" .}}
</body> </body>
</html> </html>

View File

@ -3,10 +3,11 @@
<head> <head>
{{template "html-head" .}} {{template "html-head" .}}
</head> </head>
<body> <body class="app-shell">
{{template "navbar" .}} {{template "navbar" .}}
<main class="container"> <div class="app-body container-fluid px-3 px-lg-4 px-xxl-5">
<main class="container">
<div class="breadcrumb"> <div class="breadcrumb">
<a href="/performers">← Back to Performers</a> <a href="/performers">← Back to Performers</a>
</div> </div>
@ -226,7 +227,8 @@
<p class="help-text">Try importing scenes from ThePornDB or Adult Empire.</p> <p class="help-text">Try importing scenes from ThePornDB or Adult Empire.</p>
</div> </div>
{{end}} {{end}}
</main> </main>
</div>
<!-- Image Lightbox Modal --> <!-- Image Lightbox Modal -->
<div id="lightbox" class="lightbox" onclick="closeLightbox()"> <div id="lightbox" class="lightbox" onclick="closeLightbox()">

View File

@ -3,10 +3,11 @@
<head> <head>
{{template "html-head" .}} {{template "html-head" .}}
</head> </head>
<body> <body class="app-shell">
{{template "navbar" .}} {{template "navbar" .}}
<main class="container"> <div class="app-body container-fluid px-3 px-lg-4 px-xxl-5">
<main class="container">
<div class="page-header"> <div class="page-header">
<h2>Performers</h2> <h2>Performers</h2>
<form class="search-form" action="/performers" method="get"> <form class="search-form" action="/performers" method="get">
@ -68,14 +69,16 @@
<div class="empty-import-actions"> <div class="empty-import-actions">
<p class="hint">Import performers from Adult Empire without the CLI.</p> <p class="hint">Import performers from Adult Empire without the CLI.</p>
<div class="action-buttons"> <div class="action-buttons">
<button type="button" class="btn" onclick="aeImportPerformerByName()">Import performer by name</button> <button type="button" class="btn" onclick="bulkImportPerformers()">Import all performers</button>
<button type="button" class="btn btn-secondary" onclick="aeImportPerformerByName()">Import performer by name</button>
<button type="button" class="btn btn-secondary" onclick="aeImportPerformerByURL()">Import performer by URL</button> <button type="button" class="btn btn-secondary" onclick="aeImportPerformerByURL()">Import performer by URL</button>
</div> </div>
<div id="ae-status" class="status-banner"></div> <div id="ae-status" class="status-banner"></div>
</div> </div>
</div> </div>
{{end}} {{end}}
</main> </main>
</div>
{{template "html-scripts" .}} {{template "html-scripts" .}}
</body> </body>
</html> </html>

View File

@ -3,10 +3,11 @@
<head> <head>
{{template "html-head" .}} {{template "html-head" .}}
</head> </head>
<body> <body class="app-shell">
{{template "navbar" .}} {{template "navbar" .}}
<main class="container"> <div class="app-body container-fluid px-3 px-lg-4 px-xxl-5">
<main class="container">
<div class="breadcrumb"> <div class="breadcrumb">
<a href="/scenes">← Back to Scenes</a> <a href="/scenes">← Back to Scenes</a>
</div> </div>
@ -105,8 +106,8 @@
{{end}} {{end}}
{{if .Scene.URL}} {{if .Scene.URL}}
<div class="detail-row"> <div class="detail-row">
<span class="label">URL:</span> <span class="label">View / Buy:</span>
<span class="value"><a href="{{.Scene.URL}}" target="_blank">View</a></span> <span class="value"><a class="btn-link" href="{{.Scene.URL}}" target="_blank" rel="noopener">Open on TPDB</a></span>
</div> </div>
{{end}} {{end}}
</div> </div>
@ -120,7 +121,8 @@
</div> </div>
{{end}} {{end}}
</div> </div>
</main> </main>
</div>
{{template "html-scripts" .}} {{template "html-scripts" .}}
</body> </body>

View File

@ -3,10 +3,11 @@
<head> <head>
{{template "html-head" .}} {{template "html-head" .}}
</head> </head>
<body> <body class="app-shell">
{{template "navbar" .}} {{template "navbar" .}}
<main class="container"> <div class="app-body container-fluid px-3 px-lg-4 px-xxl-5">
<main class="container">
<div class="page-header"> <div class="page-header">
<h2>Scenes</h2> <h2>Scenes</h2>
<form class="search-form" action="/scenes" method="get"> <form class="search-form" action="/scenes" method="get">
@ -51,14 +52,17 @@
{{else}} {{else}}
<div class="empty-state"> <div class="empty-state">
<p>No scenes found.</p> <p>No scenes found.</p>
{{if .Query}} <div class="empty-import-actions">
<p>Try a different search term or <a href="/scenes">view all scenes</a>.</p> <p class="hint">Import scenes now.</p>
{{else}} <div class="action-buttons">
<p>Import scenes using the dashboard or CLI: <code>./goondex import scene "title"</code></p> <button type="button" class="btn" onclick="bulkImportScenes()">Import all scenes</button>
{{end}} </div>
<div id="scene-import-status" class="status-banner"></div>
</div>
</div> </div>
{{end}} {{end}}
</main> </main>
</div>
{{template "html-scripts" .}} {{template "html-scripts" .}}
</body> </body>
</html> </html>

View File

@ -3,10 +3,11 @@
<head> <head>
{{template "html-head" .}} {{template "html-head" .}}
</head> </head>
<body> <body class="app-shell">
{{template "navbar" .}} {{template "navbar" .}}
<main class="container"> <div class="app-body container-fluid px-3 px-lg-4 px-xxl-5">
<main class="container">
<div class="page-header"> <div class="page-header">
<h2>Settings</h2> <h2>Settings</h2>
<p class="help-text">Manage API keys locally. Keys are stored in <code>config/api_keys.json</code> (gitignored).</p> <p class="help-text">Manage API keys locally. Keys are stored in <code>config/api_keys.json</code> (gitignored).</p>
@ -34,7 +35,18 @@
<div id="settings-status" class="status-banner" style="margin-top: 1rem;"></div> <div id="settings-status" class="status-banner" style="margin-top: 1rem;"></div>
</div> </div>
</main>
<div class="gx-card" style="margin-top: 1.5rem; padding: 1.5rem; border: 1px solid #ff8a8a;">
<h4 style="color: #ff8a8a;">Database Maintenance</h4>
<p class="help-text">Current database: <code>{{.DBPath}}</code></p>
<div class="action-buttons" style="margin-top: 0.75rem;">
<button class="btn-secondary" onclick="loadDbInfo()">Refresh Info<div class="hoverEffect"><div></div></div></button>
<button class="btn" style="background: #ff4d4d;" onclick="confirmDeleteDb()">Delete Database<div class="hoverEffect"><div></div></div></button>
</div>
<div id="db-info" class="status-banner" style="margin-top: 0.75rem;"></div>
</div>
</main>
</div>
{{template "html-scripts" .}} {{template "html-scripts" .}}
<script> <script>
@ -90,6 +102,48 @@
el.style.display = msg ? 'block' : 'none'; el.style.display = msg ? 'block' : 'none';
} }
async function loadDbInfo() {
try {
const res = await fetch('/api/settings/database');
const result = await res.json();
if (result.success && result.data) {
const d = result.data;
const el = document.getElementById('db-info');
el.textContent = `Path: ${d.path || ''} | Size: ${ (d.size_mb || 0).toFixed ? (d.size_mb.toFixed(2) + ' MB') : 'n/a'}`;
el.classList.remove('error');
el.style.display = 'block';
}
} catch (err) {
const el = document.getElementById('db-info');
el.textContent = 'Error loading DB info: ' + err.message;
el.classList.add('error');
el.style.display = 'block';
}
}
async function confirmDeleteDb() {
if (!confirm('This will DELETE the database file and recreate an empty one. Continue?')) return;
try {
const res = await fetch('/api/settings/database', { method: 'DELETE' });
const result = await res.json();
const el = document.getElementById('db-info');
if (result.success) {
el.textContent = result.message;
el.classList.remove('error');
el.style.display = 'block';
} else {
el.textContent = result.message || 'Failed to delete DB';
el.classList.add('error');
el.style.display = 'block';
}
} catch (err) {
const el = document.getElementById('db-info');
el.textContent = 'Error deleting DB: ' + err.message;
el.classList.add('error');
el.style.display = 'block';
}
}
document.addEventListener('DOMContentLoaded', loadApiKeys); document.addEventListener('DOMContentLoaded', loadApiKeys);
</script> </script>
</body> </body>

View File

@ -3,10 +3,11 @@
<head> <head>
{{template "html-head" .}} {{template "html-head" .}}
</head> </head>
<body> <body class="app-shell">
{{template "navbar" .}} {{template "navbar" .}}
<main class="container"> <div class="app-body container-fluid px-3 px-lg-4 px-xxl-5">
<main class="container">
<div class="breadcrumb"> <div class="breadcrumb">
<a href="/studios">← Back to Studios</a> <a href="/studios">← Back to Studios</a>
</div> </div>
@ -58,7 +59,8 @@
</div> </div>
{{end}} {{end}}
</div> </div>
</main> </main>
</div>
{{template "html-scripts" .}} {{template "html-scripts" .}}
</body> </body>
</html> </html>

View File

@ -3,10 +3,11 @@
<head> <head>
{{template "html-head" .}} {{template "html-head" .}}
</head> </head>
<body> <body class="app-shell">
{{template "navbar" .}} {{template "navbar" .}}
<main class="container"> <div class="app-body container-fluid px-3 px-lg-4 px-xxl-5">
<main class="container">
<div class="page-header"> <div class="page-header">
<h2>Studios</h2> <h2>Studios</h2>
<form class="search-form" action="/studios" method="get"> <form class="search-form" action="/studios" method="get">
@ -43,14 +44,17 @@
{{else}} {{else}}
<div class="empty-state"> <div class="empty-state">
<p>No studios found.</p> <p>No studios found.</p>
{{if .Query}} <div class="empty-import-actions">
<p>Try a different search term or <a href="/studios">view all studios</a>.</p> <p class="hint">Import studios now.</p>
{{else}} <div class="action-buttons">
<p>Import studios using the dashboard or CLI: <code>./goondex import studio "name"</code></p> <button type="button" class="btn" onclick="bulkImportStudios()">Import all studios</button>
{{end}} </div>
<div id="studio-import-status" class="status-banner"></div>
</div>
</div> </div>
{{end}} {{end}}
</main> </main>
</div>
{{template "html-scripts" .}} {{template "html-scripts" .}}
</body> </body>
</html> </html>

View File

@ -0,0 +1,6 @@
{
"tpdb_api_key": "Dn8q3mdZd7mE4OHUqf7k1A3q813i48t7q1418zv87c477738",
"ae_api_key": "",
"stashdb_api_key": "",
"stashdb_endpoint": "https://stashdb.org/graphql"
}

50
scripts/enrich.sh Normal file
View File

@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Enrichment helper (Adult Empire enricher)
# Usage:
# ./scripts/enrich.sh all
# ./scripts/enrich.sh performers
# ./scripts/enrich.sh scenes
# Optional flags are passed through after the subcommand, e.g.:
# ./scripts/enrich.sh performers --start-id 100 --limit 50
set -euo pipefail
cmd="${1:-}"
shift || true
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
run() {
echo "$*"
if [[ -x "$repo_root/goondex" ]]; then
exec "$repo_root/goondex" "$@"
elif [[ -x "$repo_root/bin/goondex" ]]; then
exec "$repo_root/bin/goondex" "$@"
else
echo "goondex binary not found. Build it first with: go build -o bin/goondex ./cmd/goondex" >&2
exit 1
fi
}
case "$cmd" in
all)
run enrich all-performers "$@"
;;
performers|performer)
run enrich all-performers "$@"
;;
scenes|scene)
run enrich all-scenes "$@"
;;
*)
cat <<'EOF' >&2
Usage: ./scripts/enrich.sh {all|performers|scenes} [flags]
Examples:
./scripts/enrich.sh all
./scripts/enrich.sh performers --start-id 100 --limit 50
./scripts/enrich.sh scenes --start-id 200
EOF
exit 1
;;
esac

View File

@ -6,6 +6,16 @@ source "$ROOT/scripts/env.sh"
ADDR="${ADDR:-localhost:8788}" ADDR="${ADDR:-localhost:8788}"
# Auto-stop if already running on the same port
if command -v lsof >/dev/null 2>&1; then
pids=$(lsof -t -i "@${ADDR#*:}:${ADDR##*:}" 2>/dev/null)
if [[ -n "$pids" ]]; then
echo "Stopping existing goondex on $ADDR (pids: $pids)"
kill $pids 2>/dev/null || true
sleep 0.5
fi
fi
# Build if missing # Build if missing
if [[ ! -x "$ROOT/bin/goondex" ]]; then if [[ ! -x "$ROOT/bin/goondex" ]]; then
echo "Binary not found; building first..." echo "Binary not found; building first..."

48
scripts/set_api_key.sh Executable file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Persist TPDB (and optional AE/Stash) API keys to config/api_keys.json
# Usage:
# ./scripts/set_api_key.sh <tpdb-key> [ae-key] [stashdb-key]
#
# This writes config/api_keys.json (gitignored) and echoes an export line
# you can paste to set the env var for the current shell if desired.
set -euo pipefail
tpdb="${1:-}"
ae="${2:-}"
stash="${3:-}"
if [[ -z "$tpdb" ]]; then
echo "Usage: $0 <tpdb-key> [ae-key] [stashdb-key]" >&2
exit 1
fi
python - <<'PY' "$tpdb" "$ae" "$stash"
import json, sys, os
tpdb, ae, stash = sys.argv[1], sys.argv[2] or None, sys.argv[3] or None
path = os.path.join("config", "api_keys.json")
data = {}
if os.path.exists(path):
try:
with open(path, "r") as f:
data = json.load(f)
except Exception:
data = {}
data["tpdb_api_key"] = tpdb
if ae:
data["ae_api_key"] = ae
if stash:
data["stashdb_api_key"] = stash
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
json.dump(data, f, indent=2)
print(f"Wrote {path}")
print(f'TPDB key set: {tpdb[:4]}... (hidden)')
PY
echo "To set the env var for this shell, run:"
echo " export TPDB_API_KEY=\"${tpdb}\""

66
scripts/status.sh Executable file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Goondex status snapshot
# Usage: ./scripts/status.sh
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
# Check binary
if [[ -x "$repo_root/goondex" ]]; then
bin="$repo_root/goondex"
elif [[ -x "$repo_root/bin/goondex" ]]; then
bin="$repo_root/bin/goondex"
else
bin=""
fi
# DB info (file size)
db_path="$repo_root/goondex.db"
db_size="missing"
if [[ -f "$db_path" ]]; then
db_size=$(du -h "$db_path" | awk '{print $1}')
fi
# API key presence
keys_file="$repo_root/config/api_keys.json"
tpdb_key="missing"
if [[ -f "$keys_file" ]]; then
tpdb_key=$(python - <<'PY' "$keys_file"
import json,sys
try:
with open(sys.argv[1]) as f:
data=json.load(f)
key=data.get("tpdb_api_key")
print("set" if key else "missing")
except Exception:
print("missing")
PY
)
fi
# Basic counts (if sqlite3 is available)
scene_count="n/a"; performer_count="n/a"; studio_count="n/a"; movie_count="n/a"
if command -v sqlite3 >/dev/null 2>&1 && [[ -f "$db_path" ]]; then
scene_count=$(sqlite3 "$db_path" 'select count(*) from scenes;') || scene_count="err"
performer_count=$(sqlite3 "$db_path" 'select count(*) from performers;') || performer_count="err"
studio_count=$(sqlite3 "$db_path" 'select count(*) from studios;') || studio_count="err"
movie_count=$(sqlite3 "$db_path" 'select count(*) from movies;') || movie_count="err"
fi
# Status summary
cat <<EOF
Goondex Status
--------------
Repo: $repo_root
Binary: ${bin:-"not built"}
DB: $db_path (${db_size})
Counts: performers=$performer_count, studios=$studio_count, scenes=$scene_count, movies=$movie_count
Keys: TPDB=${tpdb_key}
EOF
# Optional: git status (concise)
if command -v git >/dev/null 2>&1; then
echo "Git:" $(git status --porcelain | wc -l) "dirty file(s)"
fi

75
scripts/tpdb_import.sh Executable file
View File

@ -0,0 +1,75 @@
#!/usr/bin/env bash
# TPDB import helper (TUI-friendly runner)
# Usage:
# ./scripts/tpdb_import.sh all
# ./scripts/tpdb_import.sh performers
# ./scripts/tpdb_import.sh studios
# ./scripts/tpdb_import.sh scenes
set -euo pipefail
cmd="${1:-}"
# Try env first, then config/api_keys.json
if [[ -z "${TPDB_API_KEY:-}" ]]; then
if [[ -f "../config/api_keys.json" ]]; then
TPDB_API_KEY="$(
python - <<'PY' "../config/api_keys.json"
import json, sys
p = sys.argv[1]
try:
with open(p) as f:
data = json.load(f)
print(data.get("tpdb_api_key", ""))
except Exception:
print("")
PY
)"
fi
fi
if [[ -z "${TPDB_API_KEY:-}" ]]; then
echo "TPDB_API_KEY is not set. Export it, or save it via scripts/set_api_key.sh." >&2
echo ' export TPDB_API_KEY="your-key-here"' >&2
exit 1
fi
run() {
echo "$*"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -x "$repo_root/goondex" ]]; then
exec "$repo_root/goondex" "$@"
elif [[ -x "$repo_root/bin/goondex" ]]; then
exec "$repo_root/bin/goondex" "$@"
else
echo "goondex binary not found. Build it first with: go build -o bin/goondex ./cmd/goondex" >&2
exit 1
fi
}
case "$cmd" in
all)
run import all
;;
performers|performer)
run import performer
;;
studios|studio)
run import studio
;;
scenes|scene)
run import scene
;;
*)
cat <<'EOF' >&2
Usage: ./scripts/tpdb_import.sh {all|performers|studios|scenes}
Examples:
./scripts/tpdb_import.sh all
./scripts/tpdb_import.sh performers
./scripts/tpdb_import.sh studios
./scripts/tpdb_import.sh scenes
EOF
exit 1
;;
esac

270
test-logo-standalone.html Normal file
View File

@ -0,0 +1,270 @@
<!DOCTYPE html>
<html>
<head>
<title>Logo Animation Test</title>
<style>
body { background: #1a1a1a; color: white; padding: 2rem; font-family: Arial, sans-serif; }
.logo { margin: 2rem 0; width: 180px; height: 110px; }
.logo svg { width: 100%; height: 100%; display: block; }
.goondex-logo-animated {
animation: logoBounce 1.5s ease-in-out infinite;
}
.goondex-logo-animated .nipple-left,
.goondex-logo-animated .nipple-right {
animation: nippleBounce 1.5s ease-in-out infinite;
}
.goondex-logo-animated .nipple-right {
animation-delay: 0.1s;
}
@keyframes logoBounce {
0% { transform: translateY(0) scaleY(1); }
15% { transform: translateY(-12px) scaleY(1.02); }
30% { transform: translateY(0) scaleY(0.85); }
40% { transform: translateY(3px) scaleY(1.05); }
100% { transform: translateY(0) scaleY(1); }
}
@keyframes nippleBounce {
0%, 100% { transform: translateY(0); }
15% { transform: translateY(-10px); }
30% { transform: translateY(0); }
40% { transform: translateY(2px); }
100% { transform: translateY(0); }
}
button { background: #ff5fa2; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; margin-right: 1rem; cursor: pointer; }
.global-loader {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.global-loader .loader-content {
background: #2a2a2a;
padding: 1.5rem 2rem;
border-radius: 12px;
border: 1px solid #444;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
color: white;
min-width: 280px;
justify-content: center;
}
.global-loader .logo svg {
width: 90px;
height: 55px;
filter: drop-shadow(0 2px 8px rgba(255, 95, 162, 0.3));
}
</style>
</head>
<body>
<h1>Goondex Logo Animation Test</h1>
<div style="margin: 2rem 0;">
<h2>Static Logo:</h2>
<div id="static-logo" class="logo">
<img src="http://localhost:8788/static/img/logo/GOONDEX_Titty.svg" alt="Goondex" width="180" height="110">
</div>
</div>
<div style="margin: 2rem 0;">
<h2>Animated Logo:</h2>
<div id="animated-logo" class="logo">
<img src="http://localhost:8788/static/img/logo/GOONDEX_Titty.svg" alt="Goondex" width="180" height="110">
</div>
</div>
<div style="margin: 2rem 0;">
<button onclick="startAnimation()">Start Animation</button>
<button onclick="stopAnimation()">Stop Animation</button>
</div>
<div style="margin: 2rem 0;">
<button onclick="testLoader()">Test Loader (3 seconds)</button>
</div>
<div id="global-loader" class="global-loader" style="display:none;">
<div class="loader-content">
<div id="loader-logo" class="logo">
<img src="http://localhost:8788/static/img/logo/GOONDEX_Titty.svg" alt="Goondex" width="90" height="55">
</div>
<div>Working...</div>
</div>
</div>
<script>
class LogoAnimator {
constructor() {
this.isAnimating = false;
this.logoElement = null;
}
init(svgElement) {
this.logoElement = svgElement;
this.identifyNipples();
}
if (!this.logoElement) return;
const paths = this.logoElement.querySelectorAll('path');
let nippleIndex = 0;
paths.forEach((path) => {
const d = path.getAttribute('d');
if (d && d.includes('1463.5643,67.636337')) {
path.classList.add('nipple-left');
nippleIndex++;
} else if (d && d.includes('70.4489,0') && nippleIndex === 1) {
path.classList.add('nipple-right');
nippleIndex++;
}
});
}
});
}
startBounce() {
if (!this.logoElement || this.isAnimating) return;
this.logoElement.classList.add('goondex-logo-animated');
this.isAnimating = true;
}
stopBounce() {
if (!this.logoElement) return;
this.logoElement.classList.remove('goondex-logo-animated');
this.isAnimating = false;
}
}
}
identifyParts() {
if (!this.logoElement) return;
const nipples = [];
const breasts = [];
const breastCandidates = [
this.logoElement.querySelector('#breast-left'),
this.logoElement.querySelector('#breast-right')
].filter(Boolean);
const nippleCandidates = [
this.logoElement.querySelector('#nipple-left'),
this.logoElement.querySelector('#nipple-right')
].filter(Boolean);
breasts.push(...breastCandidates);
nipples.push(...nippleCandidates);
if (nipples.length < 2) {
const circ = Array.from(this.logoElement.querySelectorAll('circle, ellipse'));
while (nipples.length < 2 && circ.length) nipples.push(circ.shift());
}
if (breasts.length < 2) {
const shapes = Array.from(this.logoElement.querySelectorAll('path, polygon, rect'));
while (breasts.length < 2 && shapes.length) breasts.push(shapes.shift());
}
if (breasts.length === 0) breasts.push(this.logoElement);
if (breasts.length === 1) breasts.push(this.logoElement);
if (breasts[0]) breasts[0].classList.add('breast-left');
if (breasts[1]) breasts[1].classList.add('breast-right');
if (nipples.length === 0) nipples.push(breasts[0], breasts[1]);
nipples.slice(0, 2).forEach((el, idx) => el && el.classList.add(idx === 0 ? 'nipple-left' : 'nipple-right'));
}
startBounce() {
if (!this.logoElement || this.isAnimating) return;
this.logoElement.classList.add('goondex-logo-animated');
this.isAnimating = true;
}
stopBounce() {
if (!this.logoElement) return;
this.logoElement.classList.remove('goondex-logo-animated');
this.isAnimating = false;
}
}
async function loadSVG(urls, targetId) {
const target = document.getElementById(targetId);
if (!target) return null;
for (const url of urls) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error('fetch failed');
const svgText = await res.text();
target.innerHTML = svgText;
const svg = target.querySelector('svg');
return svg;
} catch (e) {
continue;
}
}
// Fallback to img if all fetches fail (no animation possible)
target.innerHTML = `<img src=\"${urls[0]}\" alt=\"Goondex Logo\" width=\"100%\" height=\"100%\">`;
return null;
}
const logoURLs = [
"/static/img/logo/GOONDEX_Titty.svg",
"static/img/logo/GOONDEX_Titty.svg",
"./static/img/logo/GOONDEX_Titty.svg"
let animator = null;
let loaderAnimator = null;
function initLogos() {
const animatedLogo = document.querySelector('#animated-logo img');
const loaderLogo = document.querySelector('#loader-logo img');
if (animatedLogo) {
animator = new LogoAnimator();
animator.init(animatedLogo);
console.log('Animator initialized');
}
if (loaderLogo) {
loaderAnimator = new LogoAnimator();
loaderAnimator.init(loaderLogo);
console.log('Loader animator initialized');
}
}
function startAnimation() {
if (animator) animator.startBounce();
}
function stopAnimation() {
if (animator) animator.stopBounce();
}
function testLoader() {
const loader = document.getElementById('global-loader');
loader.style.display = 'flex';
if (loaderAnimator) {
loaderAnimator.startBounce();
}
setTimeout(() => {
loader.style.display = 'none';
if (loaderAnimator) {
loaderAnimator.stopBounce();
}
}, 3000);
}
initLogos();
</script>
</body>
</html>

64
test-logo.html Normal file
View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<title>Logo Animation Test</title>
<link rel="stylesheet" href="/static/css/goondex.css">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/logo-animation.css">
</head>
<body style="background: #1a1a1a; color: white; padding: 2rem;">
<h1>Goondex Logo Animation Test</h1>
<div style="margin: 2rem 0;">
<h2>Static Logo:</h2>
<div class="logo">
<img src="/static/img/logo/GOONDEX_Titty.svg" alt="Goondex" width="180" height="110">
</div>
</div>
<div style="margin: 2rem 0;">
<h2>Animated Logo:</h2>
<div class="logo">
<img id="animated-logo" src="/static/img/logo/GOONDEX_Titty.svg" alt="Goondex" width="180" height="110">
</div>
</div>
<div style="margin: 2rem 0;">
<button onclick="startAnimation()" style="background: #ff5fa2; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; margin-right: 1rem;">Start Animation</button>
<button onclick="stopAnimation()" style="background: #666; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px;">Stop Animation</button>
</div>
<div style="margin: 2rem 0;">
<h2>Loader Test:</h2>
<button onclick="testLoader()" style="background: #ff5fa2; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px;">Test Loader (3 seconds)</button>
</div>
<script src="/static/js/logo-animation.js"></script>
<script src="/static/js/app.js"></script>
<script>
let animator = null;
function startAnimation() {
const logo = document.getElementById('animated-logo');
if (!animator) {
animator = new LogoAnimator();
animator.init(logo);
}
animator.startBounce();
}
function stopAnimation() {
if (animator) {
animator.stopBounce();
}
}
function testLoader() {
showLoader('Testing logo animation in loader...');
setTimeout(() => {
hideLoader();
}, 3000);
}
</script>
</body>
</html>