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)
- ✅ Pluggable scraper architecture
- ✅ 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)
## Architecture
```
Scrapers (TPDB, AE, etc.)
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)
```

View File

@ -480,7 +480,7 @@ var webCmd = &cobra.Command{
}
defer database.Close()
server, err := web.NewServer(database, addr)
server, err := web.NewServer(database, addr, dbPath)
if err != nil {
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
- [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
- [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
## TODO
- [ ] Implement bulk studio import (`./goondex import all-studios`) with the same pagination/resume flow as the performer importer.
- [ ] Implement bulk scene import (`./goondex import all-scenes`) and wire the CLI/UI to the new data set.
## TODO (v0.1.0-dev4+)
- [ ] Add image ingestion pipeline (WebP downscale, cached thumbs) for performers (multi-image support) and scenes; make it non-blocking with concurrency caps.
- [ ] 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.
- [ ] Align the web stack on a single CSS pipeline (deprecate legacy `style.css`, keep goondex + scoped component files).
- [ ] Add lightweight UI validation (lint/smoke tests) for navigation, modals, and search to catch regressions early.
## DONE
- [x] 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] 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`).

View File

@ -27,6 +27,8 @@ CREATE TABLE IF NOT EXISTS performers (
tattoo_description TEXT,
piercing_description TEXT,
boob_job TEXT,
circumcised INTEGER DEFAULT 0,
pubic_hair_type TEXT DEFAULT 'natural',
-- Career information
career TEXT,
@ -182,6 +184,19 @@ CREATE TABLE IF NOT EXISTS scene_tags (
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)
CREATE TABLE IF NOT EXISTS scene_images (
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'),
('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
INSERT OR IGNORE INTO tags (name, category_id, description) VALUES
('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
}
// 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/model"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/tpdb"
)
@ -24,18 +25,145 @@ type ProgressCallback func(update ProgressUpdate)
// Service handles bulk import operations
type Service struct {
db *db.DB
scraper *tpdb.Scraper
db *db.DB
scraper *tpdb.Scraper
bulkScraper scraper.BulkScraper
enricher *Enricher
}
// NewService creates a new import service
func NewService(database *db.DB, scraper *tpdb.Scraper) *Service {
return &Service{
db: database,
scraper: scraper,
db: database,
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
type ImportResult struct {
EntityType string
@ -67,6 +195,15 @@ func (s *Service) BulkImportAllPerformersWithProgress(ctx context.Context, progr
// Update total on first page
if meta != nil && page == 1 {
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
@ -76,6 +213,9 @@ func (s *Service) BulkImportAllPerformersWithProgress(ctx context.Context, progr
result.Failed++
} else {
result.Imported++
if s.enricher != nil {
s.enricher.EnrichPerformer(ctx, &performer)
}
}
// 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)
// Check if we've reached the last page
if meta == nil || page >= meta.LastPage {
break
}
page++
}
@ -126,6 +261,14 @@ func (s *Service) BulkImportAllStudiosWithProgress(ctx context.Context, progress
// Update total on first page
if meta != nil && page == 1 {
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
@ -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)
// Check if we've reached the last page
if meta == nil || page >= meta.LastPage {
break
}
page++
}
@ -188,6 +326,14 @@ func (s *Service) BulkImportAllScenesWithProgress(ctx context.Context, progress
// Update total on first page
if meta != nil && page == 1 {
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
@ -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)
// Check if we've reached the last page
if meta == nil || page >= meta.LastPage {
break
}
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"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"git.leaktechnologies.dev/stu/Goondex/internal/config"
"git.leaktechnologies.dev/stu/Goondex/internal/db"
import_service "git.leaktechnologies.dev/stu/Goondex/internal/import"
"git.leaktechnologies.dev/stu/Goondex/internal/model"
"git.leaktechnologies.dev/stu/Goondex/internal/config"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/adultemp"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper/tpdb"
"git.leaktechnologies.dev/stu/Goondex/internal/scraper"
"git.leaktechnologies.dev/stu/Goondex/internal/search"
"git.leaktechnologies.dev/stu/Goondex/internal/sync"
)
@ -33,9 +34,10 @@ type Server struct {
db *db.DB
templates *template.Template
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")
if err != nil {
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,
templates: tmpl,
addr: addr,
dbPath: dbPath,
}, nil
}
@ -91,6 +94,7 @@ func (s *Server) Start() error {
// Settings endpoints
mux.HandleFunc("/api/settings/api-keys", s.handleAPISettingsKeys)
mux.HandleFunc("/api/settings/database", s.handleAPIDatabase)
// API
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")
apiKey, err := tpdbAPIKey()
if writeTPDBError(w, err) {
return
}
scraper := tpdb.NewScraper("https://api.theporndb.net", apiKey)
service := import_service.NewService(s.db, scraper)
result, err := service.BulkImportAllPerformers(context.Background())
// Try Adult Empire first (primary scraper for new imports)
bulkScraper, err := scraper.NewAdultEmpireBulkScraper()
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
}
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Imported %d/%d performers", result.Imported, result.Total),
Data: result,
})
// Use Adult Empire scraper
service := import_service.NewFlexibleService(s.db, bulkScraper)
result, err := service.BulkImportAllPerformersFlexible(context.Background())
s.writeImportResult(w, result, err, "Performers")
}
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) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
if query == "" {
json.NewEncoder(w).Encode(APIResponse{
@ -1331,29 +1345,36 @@ func (s *Server) handleAPIGlobalSearch(w http.ResponseWriter, r *http.Request) {
return
}
performerStore := db.NewPerformerStore(s.db)
studioStore := db.NewStudioStore(s.db)
sceneStore := db.NewSceneStore(s.db)
tagStore := db.NewTagStore(s.db)
// Use advanced search for complex queries
advancedSearch := search.NewAdvancedSearch(s.db)
results, err := advancedSearch.Search(query, 20)
if err != nil {
json.NewEncoder(w).Encode(APIResponse{
Success: false,
Message: fmt.Sprintf("Search failed: %v", err),
})
return
}
performers, _ := performerStore.Search(query)
studios, _ := studioStore.Search(query)
scenes, _ := sceneStore.Search(query)
tags, _ := tagStore.Search(query)
// Convert to format expected by frontend
scenes := make([]model.Scene, len(results))
for i, result := range results {
scenes[i] = result.Scene
}
results := map[string]interface{}{
"performers": performers,
"studios": studios,
"scenes": scenes,
"tags": tags,
"total": len(performers) + len(studios) + len(scenes) + len(tags),
response := map[string]interface{}{
"scenes": scenes,
"total": len(results),
"advanced": true,
"search_query": query,
}
json.NewEncoder(w).Encode(APIResponse{
Success: true,
Message: fmt.Sprintf("Found %d results", results["total"]),
Data: results,
Message: fmt.Sprintf("Found %d advanced results", len(results)),
Data: response,
})
}
// ============================================================================
@ -1369,6 +1390,7 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"PageTitle": "Settings",
"ActivePage": "settings",
"DBPath": s.dbPath,
}
s.render(w, "settings.html", data)
@ -1386,14 +1408,14 @@ func (s *Server) handleAPISettingsKeys(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
keys := config.GetAPIKeys()
resp := map[string]interface{}{
"tpdbConfigured": keys.TPDBAPIKey != "",
"aeConfigured": keys.AEAPIKey != "",
"stashdbConfigured": keys.StashDBAPIKey != "",
"stashdbEndpoint": keys.StashDBEndpoint,
"tpdb_api_key": keys.TPDBAPIKey, // local-only UI; if you prefer, mask these
"ae_api_key": keys.AEAPIKey,
"stashdb_api_key": keys.StashDBAPIKey,
"stashdb_endpoint": keys.StashDBEndpoint, // duplicate for UI convenience
"tpdbConfigured": keys.TPDBAPIKey != "",
"aeConfigured": keys.AEAPIKey != "",
"stashdbConfigured": keys.StashDBAPIKey != "",
"stashdbEndpoint": keys.StashDBEndpoint,
"tpdb_api_key": keys.TPDBAPIKey, // local-only UI; if you prefer, mask these
"ae_api_key": keys.AEAPIKey,
"stashdb_api_key": keys.StashDBAPIKey,
"stashdb_endpoint": keys.StashDBEndpoint, // duplicate for UI convenience
}
json.NewEncoder(w).Encode(APIResponse{
Success: true,
@ -1427,3 +1449,40 @@ func (s *Server) handleAPISettingsKeys(w http.ResponseWriter, r *http.Request) {
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;
cursor: pointer;
color: var(--color-text-primary);
background: var(--color-bg-elevated);
color: #fff;
background: var(--color-brand);
border: 1px solid var(--color-border-soft);
border: 1px solid var(--color-brand);
transition: background var(--transition),
border-color var(--transition),
box-shadow var(--transition),
transform var(--transition-fast);
}
/* Hover glow (SUBTLE, medium intensity) */
.btn:hover {
background: var(--color-bg-card);
border-color: var(--color-brand);
box-shadow: var(--shadow-glow-pink-soft);
transform: translateY(-2px);
background: var(--color-brand-hover);
border-color: var(--color-brand-hover);
transform: none;
}
/* Active press */
@ -58,21 +55,16 @@
.btn-primary,
.btn.brand,
.btn.pink {
background: linear-gradient(
135deg,
var(--color-brand) 0%,
var(--color-brand-hover) 90%
);
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-hover));
border: none;
color: #fff;
text-shadow: 0 0 8px rgba(255, 255, 255, 0.25);
text-shadow: none;
}
.btn-primary:hover,
.btn.brand:hover,
.btn.pink:hover {
box-shadow: var(--shadow-glow-pink);
transform: translateY(-2px);
transform: none;
}
@ -80,15 +72,30 @@
* SECONDARY BUTTON
* ================================ */
.btn-secondary {
background: var(--color-bg-card);
border: 1px solid var(--color-border-soft);
color: var(--color-text-primary);
background: transparent;
border: 2px solid var(--color-brand);
color: var(--color-brand);
}
.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);
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 {
transform: translateY(-1px);
transform: none;
}

View File

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

View File

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

View File

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

View File

@ -4,58 +4,85 @@
*/
/* ================================
* MAIN PAGE WRAPPING
* MAIN APP SHELL
* =================================== */
body {
display: flex;
justify-content: center;
align-items: stretch;
background: var(--color-bg-dark);
min-height: 100vh;
}
/* Main content (center column) */
.main-wrapper {
flex: 1;
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
color: var(--color-text-primary);
}
.app-body {
width: 100%;
max-width: 1800px;
overflow-y: auto;
padding-bottom: 4rem;
margin: 0 auto;
padding: 1.5rem 0 3.5rem;
}
.main-wrapper {
width: 100%;
}
/* Shared 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%;
height: auto;
display: block;
object-fit: cover;
opacity: 0.75;
transition: opacity 0.25s ease;
max-width: none;
margin: 0 auto;
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.side-panel img:hover {
opacity: 1;
@media (min-width: 1200px) {
.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 {
background: var(--color-bg-card);
border-bottom: 1px solid var(--color-border-soft);
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-border);
padding: 0.85rem 0;
position: sticky;
top: 0;
z-index: 40;
backdrop-filter: blur(6px);
box-shadow: var(--shadow-glow-pink-soft);
}
.nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 1800px;
margin: 0 auto;
}
/* Bootstrap navbar controls */
@ -126,36 +154,20 @@ body {
* =================================== */
.hero-section {
background: linear-gradient(
135deg,
rgba(255, 79, 163, 0.10),
rgba(216, 132, 226, 0.05)
);
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-soft);
padding: 4rem 3rem;
margin-bottom: 3rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 3rem 2.5rem;
margin-bottom: 2rem;
position: relative;
overflow: hidden;
box-shadow: var(--shadow-glow-pink-soft);
}
/* 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;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.hero-title {
font-size: 3.2rem;
font-size: 2.8rem;
font-weight: 800;
background: linear-gradient(
135deg,
@ -170,8 +182,18 @@ body {
margin-top: 1rem;
font-size: 1.2rem;
color: var(--color-text-secondary);
max-width: 580px;
margin-inline: auto;
max-width: 720px;
}
.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 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
gap: 1.25rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border-soft);
border-radius: var(--radius);
padding: 1.5rem;
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 1.4rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
transition: transform 0.20s var(--transition),
box-shadow 0.20s var(--transition);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-glow-pink);
transition: none;
}
.stat-icon {
@ -210,7 +226,7 @@ body {
}
.stat-content .stat-value {
font-size: 2rem;
font-size: 1.9rem;
font-weight: 700;
}
@ -241,24 +257,17 @@ body {
* RESPONSIVE BREAKPOINTS
* =================================== */
/* --- Large screens under 1600px --- */
@media (max-width: 1600px) {
.side-panel {
width: 180px;
}
}
/* --- Hide side panels under 900px --- */
/* --- Small screens --- */
@media (max-width: 900px) {
.side-panel {
display: none;
}
.main-wrapper {
padding: 0 0.5rem;
}
.logo-img {
height: 36px;
}
.hero-actions {
justify-content: flex-start;
}
}
/* --- 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;
}
.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 */
.breadcrumb {
margin-bottom: 1.5rem;
@ -641,6 +737,17 @@ main.container {
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 {
grid-column: 1 / -1;
}

View File

@ -8,28 +8,29 @@
* =========================== */
:root {
/* --- BRAND IDENTITY --- */
--color-brand: #FF4FA3; /* Flamingo Pink (core) */
--color-brand-hover: #FF6AB7; /* Slightly brighter pink */
--color-brand-glow: rgba(255, 79, 163, 0.35); /* SUBTLE neon glow */
--color-brand: #FF4FA3; /* Flamingo Pulse Pink */
--color-brand-strong: #d74280; /* Deep Flamingo (new) */
--color-brand-hover: #d74280; /* Hover uses deeper pink */
--color-brand-glow: transparent; /* Flat theme: no glow */
/* --- TEXT --- */
--color-text-primary: #F5F5F7;
--color-text-secondary: #A0A3AB;
--color-header: #E08FEA;
--color-keypoint: #FF6ACB;
--color-text-primary: #F8F8F8;
--color-text-secondary: #9BA0A8;
--color-header: #D78BE0;
--color-keypoint: #FF66C4;
/* --- ALERTS --- */
--color-warning: #FFAA88;
--color-info: #7EE7E7;
/* --- BACKGROUND LAYERS (dark only) --- */
--color-bg-dark: #0A0A0C;
--color-bg-card: #151517;
--color-bg-elevated: #212124;
/* --- BACKGROUND LAYERS (plum-forward dark) --- */
--color-bg-dark: #2f2333; /* Plum base */
--color-bg-card: #3a2b40; /* Card plum */
--color-bg-elevated: #44344a; /* Elevated plum */
/* --- BORDERS --- */
--color-border: #3d3d44;
--color-border-soft: rgba(255, 79, 163, 0.15); /* Flamingo soft border */
--color-border: #59475f;
--color-border-soft: #59475f;
/* --- RADII --- */
--radius: 12px;
@ -42,10 +43,10 @@
/* --- UI GRID --- */
--rail-width: 180px;
/* --- GLOWS + SHADOWS (medium intensity only) --- */
--shadow-glow-pink: 0 0 18px rgba(255, 79, 163, 0.28);
--shadow-glow-pink-soft: 0 0 38px rgba(255, 79, 163, 0.14);
--shadow-elevated: 0 6px 22px rgba(0, 0, 0, 0.6);
/* --- SHADOWS (flattened) --- */
--shadow-glow-pink: none;
--shadow-glow-pink-soft: none;
--shadow-elevated: none;
}
/* ===========================
@ -82,12 +83,12 @@ body {
::-webkit-scrollbar-thumb {
background: var(--color-brand);
border-radius: 6px;
box-shadow: var(--shadow-glow-pink-soft);
box-shadow: none;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-brand-hover);
box-shadow: var(--shadow-glow-pink);
box-shadow: none;
}
/* ===========================
@ -105,22 +106,38 @@ body {
/* Subtle glowing border */
.glow-border {
border: 1px solid var(--color-border-soft);
box-shadow: var(--shadow-glow-pink-soft);
box-shadow: none;
}
/* Card elevation */
.elevated {
background: var(--color-bg-elevated);
box-shadow: var(--shadow-elevated);
box-shadow: none;
}
/* Brand glow text (subtle) */
.text-glow {
text-shadow: 0 0 12px var(--color-brand-glow);
text-shadow: none;
}
/* Pink glow panel (subtle accent for navbar or hero) */
.panel-glow {
box-shadow: inset 0 0 60px rgba(255, 79, 163, 0.08),
0 0 22px rgba(255, 79, 163, 0.20);
box-shadow: none;
}
/* 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:deskcolor="#505050"
inkscape:document-units="px"
inkscape:zoom="2.8284271"
inkscape:cx="1382.9241"
inkscape:cy="89.095455"
inkscape:zoom="0.70710678"
inkscape:cx="776.40325"
inkscape:cy="353.55339"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g9"
showgrid="true">
showgrid="false"
showguides="true">
<inkscape:page
x="0"
y="0"
@ -42,7 +43,7 @@
margin="0"
bleed="0" />
<inkscape:page
x="610"
x="611"
y="0"
width="600"
height="180"
@ -65,7 +66,7 @@
opacity="0.14901961"
empspacing="5"
enabled="true"
visible="true" />
visible="false" />
<inkscape:page
x="1220"
y="0"
@ -82,6 +83,38 @@
id="page20"
margin="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>
<defs
id="defs1" />
@ -89,11 +122,6 @@
inkscape:label="Layer 1"
inkscape:groupmode="layer"
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
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"
@ -134,54 +162,144 @@
<g
inkscape:groupmode="layer"
id="layer2"
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" />
inkscape:label="Layer 2" />
<g
inkscape:groupmode="layer"
id="g9"
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
id="g20">
id="g8">
<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" />
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
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" />
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="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>
<path
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"
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
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"
id="path7"
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.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 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"
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>
</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);
if (modal) {
modal.classList.add('active');
}
}
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('active');
// ============================================================================
// Logo Animation for Loading Screens
// ============================================================================
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
// Global Search
let searchTimeout;
@ -97,151 +128,62 @@ function displayGlobalSearchResults(data) {
// Bulk Import Functions
async function bulkImportAll() {
if (!confirm('This will import ALL data from TPDB. This may take several hours. Continue?')) {
return;
}
setImportStatus('import-all', 'Importing all data from TPDB... This may take a while.', false);
showLoader('Importing everything...');
startJobProgress('Full library import');
try {
const response = await fetch('/api/import/all', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
let message = result.message + '\n\n';
if (result.data) {
result.data.forEach(r => {
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);
await importWithProgress('/api/import/all-performers/progress', 'Performers');
await importWithProgress('/api/import/all-studios/progress', 'Studios');
await importWithProgress('/api/import/all-scenes/progress', 'Scenes');
setImportStatus('import-all', 'Import complete', true);
setTimeout(() => location.reload(), 1500);
} catch (err) {
setImportStatus('import-all', `Import error: ${err.message}`, false);
} finally {
stopJobProgress();
hideLoader();
}
}
async function bulkImportPerformers() {
if (!confirm('This will import ALL performers from TPDB. Continue?')) {
return;
showLoader('Importing performers...');
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() {
if (!confirm('This will import ALL studios from TPDB. Continue?')) {
return;
showLoader('Importing studios...');
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() {
if (!confirm('This will import ALL scenes from TPDB. Continue?')) {
return;
showLoader('Importing scenes...');
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() {
@ -291,6 +233,7 @@ async function aeImportPerformerByName() {
const name = prompt('Import performer by name (Adult Empire):');
if (!name) return;
setAEStatus(`Searching Adult Empire for "${name}"...`);
showLoader(`Importing performer "${name}" from Adult Empire...`);
try {
const res = await fetch('/api/ae/import/performer', {
method: 'POST',
@ -306,6 +249,8 @@ async function aeImportPerformerByName() {
}
} catch (err) {
setAEStatus(`Error: ${err.message}`, true);
} finally {
hideLoader();
}
}
@ -313,6 +258,7 @@ async function aeImportPerformerByURL() {
const url = prompt('Paste Adult Empire performer URL:');
if (!url) return;
setAEStatus('Importing performer from Adult Empire URL...');
showLoader('Importing performer from Adult Empire URL...');
try {
const res = await fetch('/api/ae/import/performer-by-url', {
method: 'POST',
@ -328,6 +274,8 @@ async function aeImportPerformerByURL() {
}
} catch (err) {
setAEStatus(`Error: ${err.message}`, true);
} finally {
hideLoader();
}
}
@ -335,6 +283,7 @@ async function aeImportSceneByName() {
const title = prompt('Import scene by title (Adult Empire):');
if (!title) return;
setAEStatus(`Searching Adult Empire for "${title}"...`);
showLoader(`Importing scene "${title}" from Adult Empire...`);
try {
const res = await fetch('/api/ae/import/scene', {
method: 'POST',
@ -350,6 +299,8 @@ async function aeImportSceneByName() {
}
} catch (err) {
setAEStatus(`Error: ${err.message}`, true);
} finally {
hideLoader();
}
}
@ -357,6 +308,7 @@ async function aeImportSceneByURL() {
const url = prompt('Paste Adult Empire scene URL:');
if (!url) return;
setAEStatus('Importing scene from Adult Empire URL...');
showLoader('Importing scene from Adult Empire URL...');
try {
const res = await fetch('/api/ae/import/scene-by-url', {
method: 'POST',
@ -372,6 +324,8 @@ async function aeImportSceneByURL() {
}
} catch (err) {
setAEStatus(`Error: ${err.message}`, true);
} finally {
hideLoader();
}
}
@ -539,6 +493,98 @@ function setImportStatus(type, message, success) {
}
// 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) {
if (event.target.classList.contains('modal')) {
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">
<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>
<body>
<body class="app-shell">
{{template "navbar" .}}
<!-- LEFT LUXURY SIDE PANEL -->
<div class="side-panel left">
<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 -->
<div class="app-body container-fluid px-3 px-lg-4 px-xxl-5">
<main class="content-stack">
<section class="hero-section">
<div class="section-kicker">Control center</div>
<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">
<button class="btn" onclick="bulkImportAll()">
TPDB Bulk Import
<button type="button" class="btn btn-light-primary" onclick="bulkImportAll()">
Full Import
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="syncAll()">
Sync Data
<button type="button" class="btn-secondary" onclick="syncAll()">
Sync Library
<div class="hoverEffect"><div></div></div>
</button>
</div>
</section>
<!-- SEARCH -->
<section class="search-section" style="margin-bottom: 2.5rem;">
<input type="text" id="global-search" class="input"
placeholder="Search performers, studios, scenes, or tags...">
<div id="global-search-results" class="search-results"></div>
</section>
<div class="row g-4 align-items-stretch">
<div class="col-12 col-xl-8">
<section class="surface-panel content-stack h-100">
<div class="section-header">
<div>
<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="stats-grid">
<!-- Performers -->
<div class="stat-card">
<div class="stat-icon">👤</div>
<div class="stat-content">
<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>
<section class="search-section mb-0">
<input type="text" id="global-search" class="input"
placeholder="Search performers, studios, scenes, or tags...">
<div id="global-search-results" class="search-results"></div>
</section>
</section>
</div>
<!-- Studios -->
<div class="stat-card">
<div class="stat-icon">🏢</div>
<div class="stat-content">
<div class="stat-value">{{.StudioCount}}</div>
<div class="stat-label">Studios</div>
</div>
<div class="stat-actions">
<a href="/studios" class="stat-link">View all →</a>
<div class="col-12 col-xl-4">
<section class="surface-panel content-stack h-100">
<div class="section-header">
<div>
<div class="section-kicker">Quick commands</div>
<div class="section-title">One-click control</div>
</div>
</div>
<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 class="section-hint">Counts update as imports finish</div>
</div>
<!-- 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 class="stats-grid">
<!-- Performers -->
<div class="stat-card">
<div class="stat-icon">👤</div>
<div class="stat-content">
<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 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>
<!-- Studios -->
<div class="stat-card">
<div class="stat-icon">🏢</div>
<div class="stat-content">
<div class="stat-value">{{.StudioCount}}</div>
<div class="stat-label">Studios</div>
</div>
<div class="stat-actions">
<a href="/studios" class="stat-link">View all →</a>
</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>
</section>
<!-- TPDB IMPORT/SYNC -->
<section class="import-section">
<h3 id="ae-import">TPDB Import & Sync</h3>
<p class="help-text">
Run bulk imports from TPDB, then enrich with AE/StashDB. Keep it running to build a complete base.
<section class="surface-panel content-stack">
<div class="section-header">
<div>
<div class="section-kicker">Pipeline</div>
<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>
<div class="import-buttons">
<button class="btn" onclick="bulkImportAll()">
Import Everything (TPDB)
<button type="button" class="btn" onclick="bulkImportAll()">
Import Everything
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="bulkImportPerformers()">
Import All Performers
<button type="button" class="btn-secondary" onclick="bulkImportPerformers()">
Import Performers
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="bulkImportStudios()">
Import All Studios
<button type="button" class="btn-secondary" onclick="bulkImportStudios()">
Import Studios
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="bulkImportScenes()">
Import All Scenes
<button type="button" class="btn-secondary" onclick="bulkImportScenes()">
Import Scenes
<div class="hoverEffect"><div></div></div>
</button>
<button class="btn-secondary" onclick="syncAll()">
<button type="button" class="btn-secondary" onclick="syncAll()">
Sync All
<div class="hoverEffect"><div></div></div>
</button>
@ -208,12 +178,14 @@
<div id="sync-import-status" class="status-banner" style="margin-top: 0.75rem;"></div>
</section>
<!-- AE IMPORT SECTION -->
<section class="import-section">
<h3>Adult Empire Imports</h3>
<p class="help-text">
Import directly from Adult Empire via the UI with built-in progress feedback.
</p>
<section class="surface-panel content-stack">
<div class="section-header">
<div>
<div class="section-kicker">Adult Empire</div>
<div class="section-title">Direct imports</div>
</div>
<div class="section-hint">Built-in progress feedback for manual pulls.</div>
</div>
<div class="import-buttons">
<button class="btn-secondary" onclick="aeImportPerformerByName()">
@ -240,20 +212,9 @@
<div id="ae-status" class="status-banner"></div>
</section>
</main>
</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" .}}
</body>
</html>

View File

@ -23,7 +23,7 @@
{{define "navbar"}}
<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="/">
<img src="/static/img/logo/Goondex_LOGO.png" class="logo-img" alt="Goondex logo">
</a>
@ -57,4 +57,23 @@
</div>
</div>
</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}}

View File

@ -3,10 +3,11 @@
<head>
{{template "html-head" .}}
</head>
<body>
<body class="app-shell">
{{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-image">
{{if .Movie.ImageURL}}
@ -98,7 +99,8 @@
</div>
</section>
{{end}}
</main>
</main>
</div>
{{template "html-scripts" .}}
</body>
</html>

View File

@ -3,10 +3,11 @@
<head>
{{template "html-head" .}}
</head>
<body>
<body class="app-shell">
{{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">
<h2>Movies</h2>
<form class="search-form" action="/movies" method="get">
@ -60,7 +61,8 @@
{{end}}
</div>
{{end}}
</main>
</main>
</div>
{{template "html-scripts" .}}
</body>
</html>

View File

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

View File

@ -3,10 +3,11 @@
<head>
{{template "html-head" .}}
</head>
<body>
<body class="app-shell">
{{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">
<h2>Performers</h2>
<form class="search-form" action="/performers" method="get">
@ -68,14 +69,16 @@
<div class="empty-import-actions">
<p class="hint">Import performers from Adult Empire without the CLI.</p>
<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>
</div>
<div id="ae-status" class="status-banner"></div>
</div>
</div>
{{end}}
</main>
</main>
</div>
{{template "html-scripts" .}}
</body>
</html>

View File

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

View File

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

View File

@ -3,10 +3,11 @@
<head>
{{template "html-head" .}}
</head>
<body>
<body class="app-shell">
{{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">
<h2>Settings</h2>
<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>
</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" .}}
<script>
@ -90,6 +102,48 @@
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);
</script>
</body>

View File

@ -3,10 +3,11 @@
<head>
{{template "html-head" .}}
</head>
<body>
<body class="app-shell">
{{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">
<a href="/studios">← Back to Studios</a>
</div>
@ -58,7 +59,8 @@
</div>
{{end}}
</div>
</main>
</main>
</div>
{{template "html-scripts" .}}
</body>
</html>

View File

@ -3,10 +3,11 @@
<head>
{{template "html-head" .}}
</head>
<body>
<body class="app-shell">
{{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">
<h2>Studios</h2>
<form class="search-form" action="/studios" method="get">
@ -43,14 +44,17 @@
{{else}}
<div class="empty-state">
<p>No studios found.</p>
{{if .Query}}
<p>Try a different search term or <a href="/studios">view all studios</a>.</p>
{{else}}
<p>Import studios using the dashboard or CLI: <code>./goondex import studio "name"</code></p>
{{end}}
<div class="empty-import-actions">
<p class="hint">Import studios now.</p>
<div class="action-buttons">
<button type="button" class="btn" onclick="bulkImportStudios()">Import all studios</button>
</div>
<div id="studio-import-status" class="status-banner"></div>
</div>
</div>
{{end}}
</main>
</main>
</div>
{{template "html-scripts" .}}
</body>
</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}"
# 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
if [[ ! -x "$ROOT/bin/goondex" ]]; then
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>