diff --git a/.github/workflows/build_catalog.yml b/.github/workflows/build_catalog.yml new file mode 100644 index 0000000..ed652e0 --- /dev/null +++ b/.github/workflows/build_catalog.yml @@ -0,0 +1,42 @@ +name: Build & Validate OpenROM Catalog + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Validate Plugin Manifests + run: python scripts/validate_plugin.py + + - name: Build Catalog API + run: python scripts/build_catalog.py + + - name: Commit and Push Catalog Changes + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git add public/api/catalog.json + if git diff --staged --quiet; then + echo "No catalog changes to commit." + else + git commit -m "build: update catalog.json [skip ci]" + git push + fi diff --git a/manifests/m/m5dev/saturn-ssp/1.0.0/manifest.json b/manifests/m/m5dev/saturn-ssp/1.0.0/manifest.json new file mode 100644 index 0000000..a49487f --- /dev/null +++ b/manifests/m/m5dev/saturn-ssp/1.0.0/manifest.json @@ -0,0 +1,20 @@ +{ + "id": "saturn-ssp", + "name": "Sega Saturn Patcher (SSP)", + "version": "1.0.0", + "author": "m5dev", + "description": "Specialized Sega Saturn Patcher disc modification tool.", + "conversions": [ + "SSP" + ], + "tags": [ + "saturn", + "ssp", + "patcher", + "sega" + ], + "icon": "https://raw.githubusercontent.com/M5Devs/OpenROM-Plugins-Store/main/packages/saturn.png", + "download_url": "https://github.com/M5Devs/OpenROM-Plugins-Store/releases/download/v1.0.0/saturn-ssp.orplug", + "homepage": "https://github.com/M5Devs", + "license": "MIT" +} diff --git a/manifests/m/m5dev/wiiu-nkit/3.1.0/manifest.json b/manifests/m/m5dev/wiiu-nkit/3.1.0/manifest.json new file mode 100644 index 0000000..26254f1 --- /dev/null +++ b/manifests/m/m5dev/wiiu-nkit/3.1.0/manifest.json @@ -0,0 +1,22 @@ +{ + "id": "wiiu-nkit", + "name": "Wii U NKit & ZArchive Suite", + "version": "3.1.0", + "author": "m5dev", + "description": "Standalone NKit v3.1.0 suite for Wii U ZArchive compression.", + "conversions": [ + "WUA", + "WUD", + "WUX" + ], + "tags": [ + "wiiu", + "nkit", + "zarchive", + "wua" + ], + "icon": "https://raw.githubusercontent.com/M5Devs/OpenROM-Plugins-Store/main/packages/wiiu.png", + "download_url": "https://github.com/M5Devs/OpenROM-Plugins-Store/releases/download/v3.1.0/wiiu-nkit.orplug", + "homepage": "https://github.com/M5Devs", + "license": "GPL-3.0" +} diff --git a/manifests/m/m5dev/xbox360-tools/1.0.0/manifest.json b/manifests/m/m5dev/xbox360-tools/1.0.0/manifest.json new file mode 100644 index 0000000..b639db0 --- /dev/null +++ b/manifests/m/m5dev/xbox360-tools/1.0.0/manifest.json @@ -0,0 +1,21 @@ +{ + "id": "xbox360-tools", + "name": "Xbox 360 ZAR & XISO Tools", + "version": "1.0.0", + "author": "m5dev", + "description": "Extract XISO and compress to .zar for Xenia Edge.", + "conversions": [ + "ZAR", + "XISO" + ], + "tags": [ + "xbox360", + "xiso", + "zar", + "xenia" + ], + "icon": "https://raw.githubusercontent.com/M5Devs/OpenROM-Plugins-Store/main/packages/xbox360.png", + "download_url": "https://github.com/M5Devs/OpenROM-Plugins-Store/releases/download/v1.0.0/xbox360-tools.orplug", + "homepage": "https://github.com/M5Devs", + "license": "MIT" +} diff --git a/packages/.gitkeep b/packages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/api/catalog.json b/public/api/catalog.json new file mode 100644 index 0000000..e810b32 --- /dev/null +++ b/public/api/catalog.json @@ -0,0 +1,91 @@ +{ + "version": "1.0", + "generated_at": "2026-10-17T11:54:08.867684+00:00", + "total_plugins": 3, + "plugins": [ + { + "id": "saturn-ssp", + "name": "Sega Saturn Patcher (SSP)", + "version": "1.0.0", + "author": "m5dev", + "description": "Specialized Sega Saturn Patcher disc modification tool.", + "conversions": [ + "SSP" + ], + "tags": [ + "saturn", + "ssp", + "patcher", + "sega" + ], + "icon": "https://raw.githubusercontent.com/M5Devs/OpenROM-Plugins-Store/main/packages/saturn.png", + "download_url": "https://github.com/M5Devs/OpenROM-Plugins-Store/releases/download/v1.0.0/saturn-ssp.orplug", + "homepage": "https://github.com/M5Devs", + "license": "MIT", + "publisher": { + "id": "m5dev", + "name": "Yusuf Ezz (M5 Dev)", + "github": "https://github.com/M5Devs", + "is_verified": true, + "badge": "Core Architect" + } + }, + { + "id": "wiiu-nkit", + "name": "Wii U NKit & ZArchive Suite", + "version": "3.1.0", + "author": "m5dev", + "description": "Standalone NKit v3.1.0 suite for Wii U ZArchive compression.", + "conversions": [ + "WUA", + "WUD", + "WUX" + ], + "tags": [ + "wiiu", + "nkit", + "zarchive", + "wua" + ], + "icon": "https://raw.githubusercontent.com/M5Devs/OpenROM-Plugins-Store/main/packages/wiiu.png", + "download_url": "https://github.com/M5Devs/OpenROM-Plugins-Store/releases/download/v3.1.0/wiiu-nkit.orplug", + "homepage": "https://github.com/M5Devs", + "license": "GPL-3.0", + "publisher": { + "id": "m5dev", + "name": "Yusuf Ezz (M5 Dev)", + "github": "https://github.com/M5Devs", + "is_verified": true, + "badge": "Core Architect" + } + }, + { + "id": "xbox360-tools", + "name": "Xbox 360 ZAR & XISO Tools", + "version": "1.0.0", + "author": "m5dev", + "description": "Extract XISO and compress to .zar for Xenia Edge.", + "conversions": [ + "ZAR", + "XISO" + ], + "tags": [ + "xbox360", + "xiso", + "zar", + "xenia" + ], + "icon": "https://raw.githubusercontent.com/M5Devs/OpenROM-Plugins-Store/main/packages/xbox360.png", + "download_url": "https://github.com/M5Devs/OpenROM-Plugins-Store/releases/download/v1.0.0/xbox360-tools.orplug", + "homepage": "https://github.com/M5Devs", + "license": "MIT", + "publisher": { + "id": "m5dev", + "name": "Yusuf Ezz (M5 Dev)", + "github": "https://github.com/M5Devs", + "is_verified": true, + "badge": "Core Architect" + } + } + ] +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..60497e1 --- /dev/null +++ b/public/index.html @@ -0,0 +1,201 @@ + + + + + + OpenROM Plugin Store + + + +
+ +
+ +
+
+

OpenROM Plugin Store

+

Discover, install, and compress retro ROM archives effortlessly. Community-driven conversion extensions for OpenROM ecosystem.

+
+
+ +
+ +
+ +
+
+ +
+
+ + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..f986c17 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,387 @@ +:root { + --bg-dark: #0b0e14; + --bg-card: #151a23; + --bg-card-hover: #1c2330; + --text-main: #e6edf3; + --text-muted: #8b949e; + --accent-cyan: #00f2ff; + --accent-purple: #9d4edd; + --accent-blue: #38bdf8; + --border-color: #30363d; + --badge-bg: rgba(0, 242, 255, 0.1); + --badge-border: rgba(0, 242, 255, 0.3); + --btn-green: #238636; + --btn-green-hover: #2ea043; + --btn-blue: #1f6feb; + --btn-blue-hover: #388bfd; + --font-mono: 'Fira Code', 'Courier New', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--bg-dark); + color: var(--text-main); + font-family: var(--font-sans); + line-height: 1.6; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Header */ +header { + background-color: rgba(21, 26, 35, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 100; +} + +.nav-container { + max-width: 1200px; + margin: 0 auto; + padding: 1rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 700; + color: #fff; + text-decoration: none; + font-family: var(--font-mono); + letter-spacing: -0.5px; +} + +.logo-icon { + background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); + color: #000; + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 900; + font-size: 1.2rem; + box-shadow: 0 0 15px rgba(0, 242, 255, 0.4); +} + +.logo span { + color: var(--accent-cyan); +} + +.nav-links { + display: flex; + gap: 1.5rem; +} + +.nav-links a { + color: var(--text-muted); + text-decoration: none; + font-weight: 500; + font-size: 0.95rem; + transition: color 0.2s; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.nav-links a:hover { + color: var(--accent-cyan); +} + +/* Hero Section */ +.hero { + background: radial-gradient(circle at 50% 20%, rgba(157, 78, 221, 0.15), transparent 70%); + padding: 4rem 1.5rem 3rem; + text-align: center; + border-bottom: 1px solid rgba(48, 54, 61, 0.5); +} + +.hero-content { + max-width: 800px; + margin: 0 auto; +} + +.hero h1 { + font-size: 2.75rem; + font-weight: 800; + margin-bottom: 1rem; + background: linear-gradient(90deg, #ffffff, var(--accent-cyan)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + line-height: 1.2; +} + +.hero p { + font-size: 1.15rem; + color: var(--text-muted); + margin-bottom: 2rem; +} + +/* Search and Controls */ +.controls-container { + max-width: 1200px; + margin: -1.5rem auto 2rem; + padding: 0 1.5rem; + display: flex; + gap: 1rem; + align-items: center; +} + +.search-bar { + flex: 1; + position: relative; +} + +.search-bar input { + width: 100%; + padding: 0.85rem 1.25rem 0.85rem 2.8rem; + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-main); + font-size: 1rem; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.search-bar input:focus { + border-color: var(--accent-cyan); + box-shadow: 0 0 10px rgba(0, 242, 255, 0.2); +} + +.search-icon { + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); +} + +/* Main Grid */ +main { + max-width: 1200px; + margin: 0 auto; + padding: 0 1.5rem 4rem; + flex: 1; + width: 100%; +} + +.plugin-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 1.5rem; +} + +/* Plugin Card */ +.plugin-card { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1.5rem; + display: flex; + flex-direction: column; + transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s; + position: relative; + overflow: hidden; +} + +.plugin-card:hover { + transform: translateY(-3px); + border-color: rgba(0, 242, 255, 0.4); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.card-header { + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; +} + +.plugin-icon { + width: 52px; + height: 52px; + border-radius: 10px; + background-color: #21262d; + object-fit: cover; + border: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + color: var(--accent-cyan); + font-size: 1.4rem; +} + +.card-title-group { + flex: 1; +} + +.plugin-title { + font-size: 1.15rem; + font-weight: 700; + color: #fff; + line-height: 1.3; +} + +.plugin-version { + font-size: 0.8rem; + font-family: var(--font-mono); + color: var(--text-muted); + background-color: #21262d; + padding: 0.15rem 0.4rem; + border-radius: 4px; + display: inline-block; + margin-top: 0.25rem; +} + +.publisher-info { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 0.4rem; +} + +.verified-badge { + color: var(--accent-blue); + font-size: 0.9rem; + display: inline-flex; + align-items: center; + gap: 0.1rem; +} + +.badge-tag { + background-color: rgba(157, 78, 221, 0.15); + color: var(--accent-purple); + border: 1px solid rgba(157, 78, 221, 0.3); + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + border-radius: 10px; + font-weight: 600; + margin-left: 0.2rem; +} + +.plugin-description { + color: var(--text-muted); + font-size: 0.9rem; + margin-bottom: 1.25rem; + flex: 1; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 1.25rem; +} + +.tag { + background-color: var(--badge-bg); + color: var(--accent-cyan); + border: 1px solid var(--badge-border); + font-size: 0.75rem; + font-family: var(--font-mono); + padding: 0.2rem 0.5rem; + border-radius: 6px; +} + +.conversions-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.3rem; + font-weight: 600; +} + +.card-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + margin-top: auto; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.6rem 0.8rem; + border-radius: 6px; + font-weight: 600; + font-size: 0.85rem; + text-decoration: none; + cursor: pointer; + transition: background-color 0.2s, border-color 0.2s, transform 0.1s; + border: none; +} + +.btn:active { + transform: scale(0.98); +} + +.btn-install { + background-color: var(--btn-green); + color: #fff; +} + +.btn-install:hover { + background-color: var(--btn-green-hover); +} + +.btn-download { + background-color: #21262d; + color: var(--text-main); + border: 1px solid var(--border-color); +} + +.btn-download:hover { + background-color: #30363d; + border-color: var(--text-muted); +} + +/* Footer */ +footer { + border-top: 1px solid var(--border-color); + background-color: var(--bg-card); + padding: 2rem 1.5rem; + text-align: center; + color: var(--text-muted); + font-size: 0.85rem; + margin-top: auto; +} + +footer a { + color: var(--accent-cyan); + text-decoration: none; +} + +footer a:hover { + text-decoration: underline; +} + +@media (max-width: 600px) { + .hero h1 { + font-size: 2rem; + } + .card-actions { + grid-template-columns: 1fr; + } +} diff --git a/publishers/m5dev.json b/publishers/m5dev.json new file mode 100644 index 0000000..bd7616e --- /dev/null +++ b/publishers/m5dev.json @@ -0,0 +1,7 @@ +{ + "id": "m5dev", + "name": "Yusuf Ezz (M5 Dev)", + "github": "https://github.com/M5Devs", + "is_verified": true, + "badge": "Core Architect" +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..edd9d55 --- /dev/null +++ b/schema.sql @@ -0,0 +1,36 @@ +-- Cloudflare D1 Database Schema for OpenROM Plugin Store + +DROP TABLE IF EXISTS plugins; +DROP TABLE IF EXISTS publishers; + +-- Publishers table +CREATE TABLE publishers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + github TEXT, + is_verified INTEGER DEFAULT 0 NOT NULL, + badge TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +-- Plugins table +CREATE TABLE plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version TEXT NOT NULL, + author_id TEXT NOT NULL, + description TEXT NOT NULL, + conversions TEXT NOT NULL, -- JSON array string or comma-separated list + tags TEXT, -- JSON array string or comma-separated list + icon TEXT, + download_url TEXT NOT NULL, + homepage TEXT, + license TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (author_id) REFERENCES publishers(id) ON DELETE CASCADE +); + +-- Indexes for performance +CREATE INDEX idx_publishers_verified ON publishers(is_verified); +CREATE INDEX idx_plugins_author ON plugins(author_id); diff --git a/scripts/build_catalog.py b/scripts/build_catalog.py new file mode 100644 index 0000000..23830a9 --- /dev/null +++ b/scripts/build_catalog.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +from datetime import datetime, timezone +import json + +from pathlib import Path +import sys + +def load_publishers() -> dict: + publishers = {} + pub_dir = Path("publishers") + if pub_dir.exists(): + for p_file in pub_dir.glob("*.json"): + try: + with open(p_file, "r", encoding="utf-8") as f: + data = json.load(f) + if "id" in data: + publishers[data["id"]] = data + except Exception as e: + print(f"⚠️ Warning: Failed to load publisher {p_file}: {e}") + return publishers + +def build_catalog(): + manifests_dir = Path("manifests") + if not manifests_dir.exists(): + print("❌ 'manifests/' directory not found.") + sys.exit(1) + + publishers = load_publishers() + plugins = [] + + manifest_files = sorted(list(manifests_dir.rglob("manifest.json"))) + print(f"📦 Found {len(manifest_files)} manifest(s) for catalog generation.") + + for m_file in manifest_files: + try: + with open(m_file, "r", encoding="utf-8") as f: + manifest = json.load(f) + + author_id = manifest.get("author", "") + publisher_data = publishers.get(author_id, { + "id": author_id, + "name": author_id, + "github": "", + "is_verified": False, + "badge": None + }) + + plugin_entry = { + **manifest, + "publisher": publisher_data + } + plugins.append(plugin_entry) + except Exception as e: + print(f"❌ Error processing {m_file}: {e}") + sys.exit(1) + + catalog_data = { + "version": "1.0", + "generated_at": datetime.now(timezone.utc).isoformat(), + "total_plugins": len(plugins), + "plugins": plugins + } + + output_dir = Path("public/api") + output_dir.mkdir(parents=True, exist_ok=True) + catalog_file = output_dir / "catalog.json" + + with open(catalog_file, "w", encoding="utf-8") as f: + json.dump(catalog_data, f, indent=2, ensure_ascii=False) + f.write("\n") + + print(f"✨ Successfully generated catalog at {catalog_file} with {len(plugins)} plugin(s).") + +if __name__ == "__main__": + build_catalog() diff --git a/scripts/validate_plugin.py b/scripts/validate_plugin.py new file mode 100644 index 0000000..a28a6a2 --- /dev/null +++ b/scripts/validate_plugin.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +import json +import sys +from pathlib import Path +from urllib.parse import urlparse + +REQUIRED_FIELDS = ["id", "name", "version", "author", "conversions"] + +def validate_url(url_str: str, field_name: str, file_path: Path) -> bool: + if not isinstance(url_str, str) or not url_str.strip(): + print(f"❌ Error in {file_path}: '{field_name}' must be a non-empty string.") + return False + parsed = urlparse(url_str) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + print(f"❌ Error in {file_path}: '{field_name}' URL '{url_str}' is invalid.") + return False + return True + +def validate_manifest(file_path: Path) -> bool: + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + print(f"❌ Error reading {file_path}: {e}") + return False + + is_valid = True + + for field in REQUIRED_FIELDS: + if field not in data or data[field] is None: + print(f"❌ Error in {file_path}: Missing mandatory field '{field}'.") + is_valid = False + + if "id" in data and isinstance(data["id"], str): + plugin_id = data["id"] + if not plugin_id.strip(): + print(f"❌ Error in {file_path}: Field 'id' must not be empty.") + is_valid = False + + if "author" in data and isinstance(data["author"], str): + author_id = data["author"] + publisher_path = Path("publishers") / f"{author_id}.json" + if not publisher_path.exists(): + print(f"❌ Error in {file_path}: Author/Publisher '{author_id}' record not found at {publisher_path}.") + is_valid = False + + if "conversions" in data: + if not isinstance(data["conversions"], list) or len(data["conversions"]) == 0: + print(f"❌ Error in {file_path}: Field 'conversions' must be a non-empty list.") + is_valid = False + + if "download_url" in data and data["download_url"]: + if not validate_url(data["download_url"], "download_url", file_path): + is_valid = False + + if "icon" in data and data["icon"]: + if not validate_url(data["icon"], "icon", file_path): + is_valid = False + + if "homepage" in data and data["homepage"]: + if not validate_url(data["homepage"], "homepage", file_path): + is_valid = False + + return is_valid + +def main(): + manifests_dir = Path("manifests") + if not manifests_dir.exists(): + print("❌ 'manifests/' directory not found.") + sys.exit(1) + + manifest_files = list(manifests_dir.rglob("manifest.json")) + if not manifest_files: + print("⚠️ No manifest.json files found.") + sys.exit(0) + + print(f"🔍 Validating {len(manifest_files)} plugin manifest(s)...") + failed_count = 0 + + for manifest_path in manifest_files: + if validate_manifest(manifest_path): + print(f"✅ Valid: {manifest_path}") + else: + failed_count += 1 + + if failed_count > 0: + print(f"❌ Manifest validation failed with {failed_count} error(s).") + sys.exit(1) + else: + print("🎉 All plugin manifests validated successfully!") + sys.exit(0) + +if __name__ == "__main__": + main()