This commit is contained in:
2026-09-18 20:55:24 +02:00
commit 9dc1838fc2
10 changed files with 2598 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.log
View File
Executable
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Builds "AniList Companion.app" by copying the already-downloaded
# Electron.app binary and rebranding it with our app code + a custom
# Info.plist - avoids re-downloading Electron via electron-builder (disk
# space is tight), same technique electron-packager uses internally.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ELECTRON_APP="$DIR/node_modules/electron/dist/Electron.app"
OUT_APP="/Applications/AniList Companion.app"
if [ ! -d "$ELECTRON_APP" ]; then
echo "Electron.app not found at $ELECTRON_APP - run 'npm install' first." >&2
exit 1
fi
rm -rf "$OUT_APP"
cp -R "$ELECTRON_APP" "$OUT_APP"
# Rename the binary itself so it shows up correctly in Activity Monitor /
# Dock, and embed our app source into Contents/Resources/app.
mv "$OUT_APP/Contents/MacOS/Electron" "$OUT_APP/Contents/MacOS/AniList Companion"
RESOURCES="$OUT_APP/Contents/Resources"
mkdir -p "$RESOURCES/app"
cp -R "$DIR/src" "$RESOURCES/app/src"
cp "$DIR/package.json" "$RESOURCES/app/package.json"
# Custom Info.plist: proper app name, bundle id, icon reference.
cat > "$OUT_APP/Contents/Info.plist" << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>AniList Companion</string>
<key>CFBundleDisplayName</key>
<string>AniList Companion</string>
<key>CFBundleIdentifier</key>
<string>com.anilist-tracker.companion</string>
<key>CFBundleExecutable</key>
<string>AniList Companion</string>
<key>CFBundleIconFile</key>
<string>electron.icns</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
PLIST
# Re-sign ad-hoc so macOS Gatekeeper accepts the renamed/modified bundle.
codesign --force --deep --sign - "$OUT_APP" 2>&1 | tail -5
echo "Built: $OUT_APP"
+889
View File
@@ -0,0 +1,889 @@
{
"name": "anilist-app",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "anilist-app",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"electron": "^38.5.0"
}
},
"node_modules/@electron/get": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"env-paths": "^2.2.0",
"fs-extra": "^8.1.0",
"got": "^11.8.5",
"progress": "^2.0.3",
"semver": "^6.2.0",
"sumchecker": "^3.0.1"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"global-agent": "^3.0.0"
}
},
"node_modules/@electron/get/node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/@electron/get/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/@electron/get/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@electron/get/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@szmarczak/http-timer": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
"dev": true,
"license": "MIT",
"dependencies": {
"defer-to-connect": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
"integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-cache-semantics": "*",
"@types/keyv": "^3.1.4",
"@types/node": "*",
"@types/responselike": "^1.0.0"
}
},
"node_modules/@types/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/keyv": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
"integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "24.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz",
"integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/responselike": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
"integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/cacheable-lookup": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.6.0"
}
},
"node_modules/cacheable-request": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
"integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
"dev": true,
"license": "MIT",
"dependencies": {
"clone-response": "^1.0.2",
"get-stream": "^5.1.0",
"http-cache-semantics": "^4.0.0",
"keyv": "^4.0.0",
"lowercase-keys": "^2.0.0",
"normalize-url": "^6.0.1",
"responselike": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/clone-response": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
"integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-response": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/decompress-response/node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/electron": {
"version": "38.8.6",
"resolved": "https://registry.npmjs.org/electron/-/electron-38.8.6.tgz",
"integrity": "sha512-lyBhcVi9QYAZL6FO6r5twAWAjWnYomo3iVDvrb5SJZlq928BGemHOKG0tPIq41NOLaCu9f3XdEEjMkjQPjprRg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^22.7.7",
"extract-zip": "^2.0.1"
},
"bin": {
"electron": "cli.js"
},
"engines": {
"node": ">= 12.20.55"
}
},
"node_modules/electron/node_modules/@types/node": {
"version": "22.20.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz",
"integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/electron/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
"yauzl": "^2.10.0"
},
"bin": {
"extract-zip": "cli.js"
},
"engines": {
"node": ">= 10.17.0"
},
"optionalDependencies": {
"@types/yauzl": "^2.9.1"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pump": "^3.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/global-agent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"es6-error": "^4.1.1",
"matcher": "^3.0.0",
"roarr": "^2.15.3",
"semver": "^7.3.2",
"serialize-error": "^7.0.1"
},
"engines": {
"node": ">=10.0"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"define-properties": "^1.2.1",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/got": {
"version": "11.8.6",
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/is": "^4.0.0",
"@szmarczak/http-timer": "^4.0.5",
"@types/cacheable-request": "^6.0.1",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^5.0.3",
"cacheable-request": "^7.0.2",
"decompress-response": "^6.0.0",
"http2-wrapper": "^1.0.0-beta.5.2",
"lowercase-keys": "^2.0.0",
"p-cancelable": "^2.0.0",
"responselike": "^2.0.0"
},
"engines": {
"node": ">=10.19.0"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/http2-wrapper": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.0.0"
},
"engines": {
"node": ">=10.19.0"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"dev": true,
"license": "ISC",
"optional": true
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"escape-string-regexp": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/mimic-response": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/normalize-url": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true,
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
"dev": true,
"license": "MIT"
},
"node_modules/responselike": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
"integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lowercase-keys": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"detect-node": "^2.0.4",
"globalthis": "^1.0.1",
"json-stringify-safe": "^5.0.1",
"semver-compare": "^1.0.0",
"sprintf-js": "^1.1.2"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/serialize-error": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"type-fest": "^0.13.1"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/type-fest": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "anilist-app",
"version": "1.0.0",
"description": "Standalone desktop app for tracking anime on AniList, with a built-in browser running the real MAL-Sync extension.",
"main": "src/main.js",
"scripts": {
"start": "electron .",
"build:mac": "electron-builder --mac"
},
"author": "linus",
"license": "MIT",
"devDependencies": {
"electron": "^38.5.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("dashboardApi", {
fetchWatching: () => ipcRenderer.invoke("fetch-watching"),
searchAnime: (query) => ipcRenderer.invoke("search-anime", query),
addAnime: (mediaId, status) => ipcRenderer.invoke("add-anime", mediaId, status),
updateAnime: (mediaId, fields) => ipcRenderer.invoke("update-anime", mediaId, fields),
watchNow: (url, trackable) => ipcRenderer.invoke("watch-now", { url, trackable }),
fetchMediaDetail: (mediaId) => ipcRenderer.invoke("fetch-media-detail", mediaId),
onRefresh: (cb) => ipcRenderer.on("refresh", cb)
});
+924
View File
@@ -0,0 +1,924 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
:root {
--bg: #0b0b12;
--surface: #17171f;
--surface-2: #1e1e28;
--surface-3: #272734;
--border: #2a2a38;
--accent: #7c5cff;
--accent-2: #b48cff;
--accent-hover: #8f72ff;
--text: #f3f3f8;
--text-dim: #9c9cb0;
--text-faint: #62627a;
--green: #35d488;
--orange: #ff8a3d;
--radius: 16px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
background:
radial-gradient(1100px 480px at 15% -10%, rgba(124,92,255,0.16), transparent 60%),
radial-gradient(900px 420px at 100% 0%, rgba(255,138,61,0.08), transparent 55%),
var(--bg);
color: var(--text);
padding: 32px 36px 70px;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: #34344a; }
header {
margin-bottom: 26px;
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.header-left h1 {
font-size: 26px;
font-weight: 800;
letter-spacing: -0.4px;
background: linear-gradient(90deg, #fff, #c9bbff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.user-line {
color: var(--text-dim);
font-size: 13px;
margin-top: 6px;
display: flex;
align-items: center;
gap: 7px;
}
.user-dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--green);
box-shadow: 0 0 8px var(--green);
display: inline-block;
}
.stat-row { display: flex; gap: 10px; flex-wrap: wrap; }
.stat-pill {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px 16px;
text-align: center;
min-width: 78px;
}
.stat-pill .num { font-size: 18px; font-weight: 800; color: #fff; line-height: 1.1; }
.stat-pill .lbl { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .5px; margin-top: 2px; }
.search-bar {
display: flex;
gap: 10px;
margin: 6px 0 6px;
max-width: 680px;
}
.search-bar input {
flex: 1;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 13px 16px;
color: var(--text);
font-size: 14px;
transition: border-color .15s, background .15s;
}
.search-bar input::placeholder { color: var(--text-faint); }
.search-bar input:focus { outline: none; border-color: var(--accent); background: var(--surface-3); }
.search-bar button {
background: linear-gradient(135deg, var(--accent), #9b7bff);
border: none;
border-radius: 12px;
padding: 13px 22px;
color: #fff;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: transform .1s, filter .15s;
box-shadow: 0 4px 14px rgba(124,92,255,0.35);
}
.search-bar button:hover { filter: brightness(1.08); }
.search-bar button:active { transform: scale(0.97); }
#search-status { font-size: 12px; color: var(--text-faint); min-height: 16px; margin-bottom: 10px; }
#search-results { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; max-width: 680px; }
.result-card {
display: flex; gap: 12px; background: var(--surface-2); border-radius: 12px;
padding: 10px; align-items: center; border: 1px solid var(--border);
}
.result-card img { width: 40px; height: 56px; border-radius: 7px; object-fit: cover; }
.result-card .title { font-size: 13px; font-weight: 700; }
.result-card .meta { font-size: 11px; color: var(--text-faint); margin-top: 2px; }
.add-btn {
margin-left: auto; background: var(--green); border: none; border-radius: 9px;
padding: 7px 14px; color: #05230f; font-size: 12px; font-weight: 800; cursor: pointer;
white-space: nowrap;
}
.add-btn:hover { filter: brightness(1.1); }
.add-btn:disabled { background: var(--surface-3); color: var(--text-faint); cursor: default; }
.search-view-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.card-add-btn {
background: var(--green);
border: none;
border-radius: 9px;
padding: 9px 14px;
color: #05230f;
font-size: 12px;
font-weight: 800;
cursor: pointer;
align-self: flex-start;
margin-top: auto;
}
.card-add-btn:hover { filter: brightness(1.1); }
.card-add-btn:disabled { background: var(--surface-3); color: var(--text-faint); cursor: default; }
.card-meta-line { font-size: 11px; color: var(--text-faint); font-weight: 600; }
h2.section-title {
font-size: 12.5px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-faint);
margin: 30px 0 16px;
font-weight: 800;
display: flex;
align-items: center;
gap: 9px;
cursor: pointer;
user-select: none;
transition: color .12s;
}
h2.section-title:hover { color: var(--text-dim); }
h2.section-title .chevron {
display: inline-block;
transition: transform .15s;
font-size: 10px;
color: var(--accent-2);
}
h2.section-title.collapsed .chevron { transform: rotate(-90deg); }
h2.section-title .count {
background: var(--surface-3);
color: var(--text-dim);
padding: 2px 9px;
border-radius: 20px;
font-size: 11px;
font-weight: 800;
}
.grid.collapsed { display: none; }
.filter-bar {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin: 18px 0 4px;
max-width: 920px;
}
.filter-bar input[type="text"] {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 9px 13px;
color: var(--text);
font-size: 13px;
min-width: 210px;
}
.filter-bar input[type="text"]::placeholder { color: var(--text-faint); }
.filter-bar input[type="text"]:focus { outline: none; border-color: var(--accent); }
.filter-chip {
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 20px;
padding: 7px 14px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: all .12s;
}
.filter-chip:hover { border-color: #4a4a60; color: #ddd; }
.filter-chip.active {
background: linear-gradient(135deg, var(--accent), #9b7bff);
border-color: transparent;
color: #fff;
box-shadow: 0 3px 10px rgba(124,92,255,0.35);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
display: flex;
transition: border-color .18s, transform .15s, box-shadow .18s;
}
.card:hover {
border-color: rgba(124,92,255,0.4);
transform: translateY(-2px);
box-shadow: 0 10px 28px rgba(0,0,0,0.35), 0 0 0 1px rgba(124,92,255,0.08);
}
.card-cover {
width: 92px;
flex-shrink: 0;
background-size: cover;
background-position: center;
position: relative;
}
.card-cover::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 60%, rgba(23,23,31,0.5));
}
.status-pill {
position: absolute;
top: 7px;
left: 7px;
background: rgba(10,10,16,0.72);
backdrop-filter: blur(6px);
padding: 3px 8px;
border-radius: 20px;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.4px;
text-transform: uppercase;
z-index: 1;
}
.card-body {
flex: 1;
padding: 13px 15px;
display: flex;
flex-direction: column;
gap: 9px;
min-width: 0;
}
.card-title {
font-size: 14px;
font-weight: 800;
line-height: 1.28;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
letter-spacing: -0.1px;
}
.progress-row { display: flex; align-items: center; gap: 8px; }
.ep-control {
display: flex; align-items: center; gap: 2px; background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px; padding: 2px;
}
.ep-control button {
background: var(--surface-3); border: none; color: var(--text); width: 22px; height: 22px;
border-radius: 6px; cursor: pointer; font-size: 13px; line-height: 1;
display: flex; align-items: center; justify-content: center;
transition: background .12s;
}
.ep-control button:hover { background: #3a3a52; }
.ep-control .ep-value { font-size: 12px; min-width: 20px; text-align: center; font-weight: 700; }
.ep-total { font-size: 11px; color: var(--text-faint); font-weight: 600; }
.bar { height: 4px; background: var(--surface-3); border-radius: 3px; overflow: hidden; }
.bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); border-radius: 3px; transition: width .2s; }
.meta-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* Custom select shell: hides native chrome, adds our own arrow/border */
.select-wrap { position: relative; display: inline-flex; }
.select-wrap::after {
content: "";
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
width: 6px; height: 6px;
border-right: 1.5px solid var(--text-faint);
border-bottom: 1.5px solid var(--text-faint);
transform: translateY(-65%) rotate(45deg);
pointer-events: none;
}
select.status-select, select.score-select {
appearance: none;
-webkit-appearance: none;
background: var(--surface-2);
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 8px;
padding: 5px 22px 5px 9px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: border-color .12s, background .12s;
}
select.status-select:hover, select.score-select:hover { border-color: #444460; background: var(--surface-3); }
select.status-select:focus, select.score-select:focus { outline: none; border-color: var(--accent); }
.save-status { font-size: 11px; color: var(--green); min-width: 12px; font-weight: 700; }
.stream-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
.stream-btn {
border: none; border-radius: 8px; padding: 6px 12px; font-size: 11px; font-weight: 800;
cursor: pointer; color: #fff; display: flex; align-items: center; gap: 5px;
transition: filter .15s, transform .1s, box-shadow .15s;
}
.stream-btn:hover { filter: brightness(1.12); box-shadow: 0 3px 10px rgba(0,0,0,0.3); }
.stream-btn:active { transform: scale(0.96); }
.stream-btn .dot { width: 5px; height: 5px; border-radius: 50%; background: #fff; opacity: 0.85; }
.no-stream { font-size: 11px; color: var(--text-faint); font-style: italic; }
.empty {
color: var(--text-faint);
padding: 50px 0;
text-align: center;
font-size: 14px;
}
.card-cover, .card-title { cursor: pointer; }
/* Detail modal */
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(5,5,10,0.7);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
z-index: 100;
opacity: 0; pointer-events: none;
transition: opacity .15s;
}
.modal-backdrop.open { opacity: 1; pointer-events: all; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
width: min(640px, 90vw);
max-height: 84vh;
overflow-y: auto;
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
transform: translateY(10px) scale(0.98);
transition: transform .18s;
}
.modal-backdrop.open .modal { transform: translateY(0) scale(1); }
.modal-banner {
height: 160px;
background-size: cover;
background-position: center;
position: relative;
background-color: var(--surface-2);
}
.modal-banner::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.1), var(--surface) 96%);
}
.modal-close {
position: absolute; top: 14px; right: 14px;
background: rgba(10,10,16,0.6);
backdrop-filter: blur(6px);
border: none;
color: #fff;
width: 30px; height: 30px;
border-radius: 50%;
font-size: 16px;
cursor: pointer;
z-index: 2;
display: flex; align-items: center; justify-content: center;
}
.modal-close:hover { background: rgba(10,10,16,0.85); }
.modal-body { padding: 0 28px 28px; margin-top: -64px; position: relative; }
.modal-cover {
width: 110px; height: 156px;
border-radius: 12px;
object-fit: cover;
box-shadow: 0 12px 30px rgba(0,0,0,0.5);
border: 3px solid var(--surface);
}
.modal-title-row { display: flex; gap: 18px; align-items: flex-end; }
.modal-title { font-size: 20px; font-weight: 800; letter-spacing: -0.3px; margin-bottom: 6px; }
.modal-native { font-size: 12px; color: var(--text-faint); }
.modal-meta-chips { display: flex; gap: 6px; flex-wrap: wrap; margin: 16px 0; }
.meta-chip {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 5px 11px;
font-size: 11.5px;
font-weight: 700;
color: var(--text-dim);
}
.meta-chip.score { color: var(--green); }
.modal-genres { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
.genre-tag {
background: rgba(124,92,255,0.14);
color: var(--accent-2);
border-radius: 20px;
padding: 4px 12px;
font-size: 11px;
font-weight: 700;
}
.modal-desc {
font-size: 13.5px;
line-height: 1.65;
color: var(--text-dim);
max-height: 220px;
overflow-y: auto;
}
.modal-loading { padding: 60px 28px; text-align: center; color: var(--text-faint); }
</style>
</head>
<body>
<header>
<div class="header-left">
<h1>🎬 Meine AniList-Aktivität</h1>
<div class="user-line"><span class="user-dot"></span><span id="user">Lade...</span></div>
</div>
<div class="stat-row" id="stat-row"></div>
</header>
<div class="search-bar">
<input type="text" id="search-input" placeholder="Anime suchen und zur Liste hinzufügen..." />
<button id="search-btn">Suchen</button>
</div>
<div id="search-status"></div>
<div id="search-view" style="display:none">
<div class="search-view-header">
<h2 class="section-title" style="margin-top:0">Suchergebnisse</h2>
<button id="back-to-list-btn" class="filter-chip">← Zurück zur Liste</button>
</div>
<div class="grid" id="search-grid"></div>
</div>
<div id="library-view">
<div class="filter-bar">
<input type="text" id="filter-text" placeholder="Meine Liste filtern..." />
<button class="filter-chip active" data-status="ALL">Alle</button>
<button class="filter-chip" data-status="CURRENT">Am Schauen</button>
<button class="filter-chip" data-status="REPEATING">Wiederholung</button>
<button class="filter-chip" data-status="PAUSED">Pausiert</button>
<button class="filter-chip" data-status="PLANNING">Geplant</button>
<button class="filter-chip" data-status="COMPLETED">Abgeschlossen</button>
<button class="filter-chip" data-status="DROPPED">Abgebrochen</button>
</div>
<div id="list"></div>
</div>
<div class="modal-backdrop" id="modal-backdrop">
<div class="modal" id="modal-content"></div>
</div>
<script>
const STATUS_OPTIONS = [
["CURRENT", "Am Schauen"],
["COMPLETED", "Abgeschlossen"],
["PAUSED", "Pausiert"],
["DROPPED", "Abgebrochen"],
["PLANNING", "Geplant"],
["REPEATING", "Wiederholung"]
];
function animeTitle(media) {
return media.title.english || media.title.romaji;
}
function scoreOptionsHtml(scoreFormat, currentScore) {
let max, step = 1;
switch (scoreFormat) {
case "POINT_100": max = 100; step = 5; break;
case "POINT_10_DECIMAL": max = 10; step = 1; break;
case "POINT_10": max = 10; break;
case "POINT_5": max = 5; break;
case "POINT_3": max = 3; break;
default: max = 10;
}
const values = [];
for (let v = 0; v <= max; v += step) values.push(v);
return values
.map((v) => `<option value="${v}" ${currentScore === v ? "selected" : ""}>${v === 0 ? "★ -" : "★ " + v}</option>`)
.join("");
}
async function doSearch() {
const q = document.getElementById("search-input").value.trim();
const statusEl = document.getElementById("search-status");
const searchViewEl = document.getElementById("search-view");
const libraryViewEl = document.getElementById("library-view");
const gridEl = document.getElementById("search-grid");
if (!q) {
gridEl.innerHTML = "";
statusEl.textContent = "";
searchViewEl.style.display = "none";
libraryViewEl.style.display = "";
return;
}
statusEl.textContent = "Suche...";
try {
const results = await window.dashboardApi.searchAnime(q);
statusEl.textContent = results.length + " Ergebnis(se)";
libraryViewEl.style.display = "none";
searchViewEl.style.display = "";
gridEl.innerHTML = results.map(searchCardHtml).join("");
gridEl.querySelectorAll(".card-add-btn:not(:disabled)").forEach((btn) => {
btn.addEventListener("click", async () => {
btn.disabled = true;
btn.textContent = "...";
try {
await window.dashboardApi.addAnime(parseInt(btn.dataset.id, 10), "PLANNING");
btn.textContent = "✓ Hinzugefügt";
render();
} catch (e) {
btn.textContent = "Fehler";
btn.title = e.message;
btn.disabled = false;
}
});
});
} catch (e) {
statusEl.textContent = "Fehler: " + e.message;
}
}
// Same card layout as the library grid (cover, title), but with just
// an add button in place of progress/status/score/stream controls -
// this view is only ever for adding a new show, not managing one.
function searchCardHtml(m) {
const title = animeTitle(m);
const already = m.mediaListEntry;
const cover = m.coverImage.medium;
return `
<div class="card">
<div class="card-cover" style="background-image:url('${cover}')"></div>
<div class="card-body">
<div class="card-title">${title}</div>
<div class="card-meta-line">${m.episodes || "?"} Episoden · ${m.status}</div>
<button class="card-add-btn" data-id="${m.id}" ${already ? "disabled" : ""}>
${already ? "Auf Liste" : "+ Hinzufügen"}
</button>
</div>
</div>
`;
}
document.getElementById("search-btn").addEventListener("click", doSearch);
document.getElementById("search-input").addEventListener("keydown", (e) => {
if (e.key === "Enter") doSearch();
});
document.getElementById("back-to-list-btn").addEventListener("click", () => {
document.getElementById("search-input").value = "";
doSearch();
});
function flashSaved(el) {
el.textContent = "✓";
setTimeout(() => { el.textContent = ""; }, 1400);
}
// Only real, AniList-confirmed availability is ever shown - no site
// picker, no "maybe it's here" guessing. If a show has zero known
// streaming links, we say so plainly instead of offering a dead end.
function streamButtonsHtml(streamingLinks) {
if (!streamingLinks || streamingLinks.length === 0) {
return '<span class="no-stream">Keine bekannte Streaming-Quelle</span>';
}
return streamingLinks.map((l, i) => {
const bg = l.color || "#7c5cff";
return `<button class="stream-btn" style="background:${bg}" data-idx="${i}">
<span class="dot"></span>${l.site}
</button>`;
}).join("");
}
function cardHtml(e, scoreFormat) {
const title = animeTitle(e.media);
const total = e.media.episodes || null;
const pct = total ? Math.round((e.progress / total) * 100) : 0;
const scoreOptions = scoreOptionsHtml(scoreFormat, e.score);
const statusOptions = STATUS_OPTIONS
.map(([val, label]) => `<option value="${val}" ${e.status === val ? "selected" : ""}>${label}</option>`)
.join("");
const statusLabel = STATUS_OPTIONS.find(([v]) => v === e.status)?.[1] || e.status;
const cover = e.media.coverImage.large || e.media.coverImage.medium;
return `
<div class="card" data-media-id="${e.media.id}">
<div class="card-cover open-detail" data-detail-id="${e.media.id}" style="background-image:url('${cover}')">
<div class="status-pill">${statusLabel}</div>
</div>
<div class="card-body">
<div class="card-title open-detail" data-detail-id="${e.media.id}">${title}</div>
<div class="progress-row">
<div class="ep-control">
<button class="ep-minus"></button>
<span class="ep-value">${e.progress}</span>
<button class="ep-plus">+</button>
</div>
<span class="ep-total">/ ${total || "?"}</span>
</div>
<div class="bar"><div class="bar-fill" style="width:${pct}%"></div></div>
<div class="meta-row">
<div class="select-wrap"><select class="status-select">${statusOptions}</select></div>
<div class="select-wrap"><select class="score-select">${scoreOptions}</select></div>
<span class="save-status"></span>
</div>
<div class="stream-row">${streamButtonsHtml(e.streamingLinks)}</div>
</div>
</div>
`;
}
function wireCard(cardEl, entry) {
const mediaId = entry.media.id;
const total = entry.media.episodes || null;
const epValueEl = cardEl.querySelector(".ep-value");
const barFillEl = cardEl.querySelector(".bar-fill");
const saveStatusEl = cardEl.querySelector(".save-status");
let currentProgress = entry.progress;
async function setProgress(newVal) {
if (newVal < 0) newVal = 0;
if (total && newVal > total) newVal = total;
currentProgress = newVal;
epValueEl.textContent = newVal;
if (total) barFillEl.style.width = Math.round((newVal / total) * 100) + "%";
try {
await window.dashboardApi.updateAnime(mediaId, { progress: newVal });
flashSaved(saveStatusEl);
} catch (e) {
saveStatusEl.textContent = "✗";
saveStatusEl.title = e.message;
}
}
cardEl.querySelector(".ep-minus").addEventListener("click", () => setProgress(currentProgress - 1));
cardEl.querySelector(".ep-plus").addEventListener("click", () => setProgress(currentProgress + 1));
cardEl.querySelector(".status-select").addEventListener("change", async (e) => {
try {
await window.dashboardApi.updateAnime(mediaId, { status: e.target.value });
flashSaved(saveStatusEl);
} catch (err) {
saveStatusEl.textContent = "✗";
saveStatusEl.title = err.message;
}
});
cardEl.querySelector(".score-select").addEventListener("change", async (e) => {
try {
await window.dashboardApi.updateAnime(mediaId, { score: parseFloat(e.target.value) });
flashSaved(saveStatusEl);
} catch (err) {
saveStatusEl.textContent = "✗";
saveStatusEl.title = err.message;
}
});
cardEl.querySelectorAll(".stream-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const link = entry.streamingLinks[parseInt(btn.dataset.idx, 10)];
if (link) window.dashboardApi.watchNow(link.url, link.trackable);
});
});
cardEl.querySelectorAll(".open-detail").forEach((el) => {
el.addEventListener("click", () => openDetailModal(mediaId));
});
}
let cachedData = null;
let activeStatusFilter = "ALL";
let textFilter = "";
// Persist which sections are collapsed across app restarts (renderer
// reloads on every dashboard tab switch/refresh, so without this the
// "Abgeschlossen" section would re-expand every 30s poll).
const COLLAPSE_KEY = "anilist-companion-collapsed-sections";
function getCollapsedSet() {
try {
const raw = localStorage.getItem(COLLAPSE_KEY);
if (raw === null) return new Set(["COMPLETED", "DROPPED"]); // sensible first-run default
return new Set(JSON.parse(raw));
} catch {
return new Set();
}
}
function setCollapsedSet(set) {
localStorage.setItem(COLLAPSE_KEY, JSON.stringify([...set]));
}
async function render() {
const data = await window.dashboardApi.fetchWatching();
cachedData = data;
renderFromCache();
}
function renderFromCache() {
const data = cachedData;
const userEl = document.getElementById("user");
const listEl = document.getElementById("list");
if (!data.loggedIn) {
userEl.textContent = "Nicht eingeloggt starte den anilist-tracker Hub-Server";
listEl.innerHTML = "";
return;
}
userEl.textContent = data.userName;
if (!data.entries.length) {
listEl.innerHTML = '<div class="empty">Deine Liste ist leer. Such oben nach etwas Neuem!</div>';
return;
}
const statCounts = { CURRENT: 0, REPEATING: 0, PAUSED: 0, PLANNING: 0, COMPLETED: 0, DROPPED: 0 };
for (const e of data.entries) statCounts[e.status] = (statCounts[e.status] || 0) + 1;
const statLabels = [
["CURRENT", "Schaue"], ["COMPLETED", "Fertig"], ["PLANNING", "Geplant"], ["DROPPED", "Abgebr."]
];
document.getElementById("stat-row").innerHTML = statLabels
.map(([key, label]) => `<div class="stat-pill"><div class="num">${statCounts[key] || 0}</div><div class="lbl">${label}</div></div>`)
.join("");
const q = textFilter.trim().toLowerCase();
const filteredEntries = data.entries.filter((e) => {
if (activeStatusFilter !== "ALL" && e.status !== activeStatusFilter) return false;
if (q && !animeTitle(e.media).toLowerCase().includes(q)) return false;
return true;
});
if (!filteredEntries.length) {
listEl.innerHTML = '<div class="empty">Keine Treffer für diesen Filter.</div>';
return;
}
const groups = {
CURRENT: [], REPEATING: [], PAUSED: [], PLANNING: [], COMPLETED: [], DROPPED: []
};
for (const e of filteredEntries) {
(groups[e.status] || (groups[e.status] = [])).push(e);
}
const sections = [
["CURRENT", "Am Schauen"],
["REPEATING", "Wiederholung"],
["PAUSED", "Pausiert"],
["PLANNING", "Geplant"],
["COMPLETED", "Abgeschlossen"],
["DROPPED", "Abgebrochen"]
];
const collapsed = getCollapsedSet();
listEl.innerHTML = sections
.filter(([key]) => groups[key] && groups[key].length)
.map(([key, label]) => {
const isCollapsed = collapsed.has(key);
return `
<h2 class="section-title ${isCollapsed ? "collapsed" : ""}" data-section="${key}">
<span class="chevron">▾</span>${label} <span class="count">${groups[key].length}</span>
</h2>
<div class="grid ${isCollapsed ? "collapsed" : ""}" data-section-grid="${key}">
${groups[key].map((e) => cardHtml(e, data.scoreFormat)).join("")}
</div>
`;
})
.join("");
listEl.querySelectorAll(".section-title").forEach((headerEl) => {
headerEl.addEventListener("click", () => {
const key = headerEl.dataset.section;
const gridEl = listEl.querySelector(`[data-section-grid="${key}"]`);
const set = getCollapsedSet();
const nowCollapsed = !headerEl.classList.contains("collapsed");
headerEl.classList.toggle("collapsed", nowCollapsed);
gridEl.classList.toggle("collapsed", nowCollapsed);
if (nowCollapsed) set.add(key); else set.delete(key);
setCollapsedSet(set);
});
});
listEl.querySelectorAll(".card").forEach((cardEl) => {
const mediaId = parseInt(cardEl.dataset.mediaId, 10);
const entry = filteredEntries.find((e) => e.media.id === mediaId);
if (entry) wireCard(cardEl, entry);
});
}
document.getElementById("filter-text").addEventListener("input", (e) => {
textFilter = e.target.value;
if (cachedData) renderFromCache();
});
document.querySelectorAll(".filter-chip").forEach((chip) => {
chip.addEventListener("click", () => {
document.querySelectorAll(".filter-chip").forEach((c) => c.classList.remove("active"));
chip.classList.add("active");
activeStatusFilter = chip.dataset.status;
if (cachedData) renderFromCache();
});
});
// Detail modal: fetches full AniList metadata (description, genres,
// score, studio, air dates) for a single anime on demand, so cards
// stay lightweight and this richer info loads only when clicked.
const modalBackdrop = document.getElementById("modal-backdrop");
const modalContent = document.getElementById("modal-content");
function closeModal() {
modalBackdrop.classList.remove("open");
}
modalBackdrop.addEventListener("click", (e) => {
if (e.target === modalBackdrop) closeModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModal();
});
function formatDate(d) {
if (!d || !d.year) return null;
const parts = [d.year];
if (d.month) parts.unshift(String(d.month).padStart(2, "0"));
if (d.day) parts.unshift(String(d.day).padStart(2, "0"));
return parts.reverse().join(".");
}
const FORMAT_LABELS = { TV: "TV", TV_SHORT: "TV Short", MOVIE: "Film", OVA: "OVA", ONA: "ONA", SPECIAL: "Special" };
async function openDetailModal(mediaId) {
modalContent.innerHTML = '<div class="modal-loading">Lade Details…</div>';
modalBackdrop.classList.add("open");
try {
const m = await window.dashboardApi.fetchMediaDetail(mediaId);
const title = m.title.english || m.title.romaji;
const banner = m.bannerImage || m.coverImage.large;
const studio = m.studios?.nodes?.[0]?.name;
const start = formatDate(m.startDate);
const end = formatDate(m.endDate);
const airRange = start ? (end && end !== start ? `${start} ${end}` : start) : null;
const score = m.averageScore != null ? Math.round(m.averageScore) : null;
const chips = [
FORMAT_LABELS[m.format] || m.format,
m.episodes ? `${m.episodes} Ep.` : null,
m.duration ? `${m.duration} Min./Ep.` : null,
studio,
airRange
].filter(Boolean);
modalContent.innerHTML = `
<div class="modal-banner" style="background-image:url('${banner}')">
<button class="modal-close" id="modal-close-btn">✕</button>
</div>
<div class="modal-body">
<div class="modal-title-row">
<img class="modal-cover" src="${m.coverImage.large}" />
<div>
<div class="modal-title">${title}</div>
${m.title.native ? `<div class="modal-native">${m.title.native}</div>` : ""}
</div>
</div>
<div class="modal-meta-chips">
${score != null ? `<span class="meta-chip score">★ ${score}%</span>` : ""}
${chips.map((c) => `<span class="meta-chip">${c}</span>`).join("")}
</div>
<div class="modal-genres">
${(m.genres || []).map((g) => `<span class="genre-tag">${g}</span>`).join("")}
</div>
<div class="modal-desc">${(m.description || "Keine Beschreibung verfügbar.").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, "")}</div>
</div>
`;
document.getElementById("modal-close-btn").addEventListener("click", closeModal);
} catch (e) {
modalContent.innerHTML = `<div class="modal-loading">Fehler beim Laden: ${e.message}</div>`;
}
}
render();
window.dashboardApi.onRefresh(() => render());
setInterval(render, 30000);
</script>
</body>
</html>
+601
View File
@@ -0,0 +1,601 @@
const { app, BrowserWindow, BrowserView, ipcMain, session, shell } = require("electron");
const path = require("path");
const fs = require("fs");
const os = require("os");
// Path to the MAL-Sync extension we built earlier from the real MALSync
// source (github.com/MALSync/MALSync). Electron can load unpacked Chrome
// extensions directly via session.loadExtension, so the SAME extension
// code that runs in Brave also runs here, inside our own app's browser
// tab - MAL-Sync itself is unmodified, just hosted in our own window
// instead of a general-purpose browser.
const MALSYNC_EXTENSION_PATH = path.join(
require("os").homedir(),
"projects/MALSync/dist/webextension"
);
// Shares the same config file as our existing anilist-tracker hub server
// (~/.anilist-tracker/config.json), so this app reads the SAME AniList
// access token you already logged in with - no separate login needed.
const CONFIG_PATH = path.join(os.homedir(), ".anilist-tracker", "config.json");
function loadHubConfig() {
try {
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
} catch {
return null;
}
}
const WATCHING_QUERY = `
query ($userId: Int) {
MediaListCollection(userId: $userId, type: ANIME, status_in: [CURRENT, REPEATING, COMPLETED, PAUSED, DROPPED, PLANNING]) {
user { mediaListOptions { scoreFormat } }
lists {
entries {
id
progress
status
score
updatedAt
media {
id
title { romaji english }
episodes
coverImage { medium large }
bannerImage
siteUrl
externalLinks { site url type language icon color }
}
}
}
}
}
`;
const MEDIA_DETAIL_QUERY = `
query ($id: Int) {
Media(id: $id) {
id
title { romaji english native }
description(asHtml: false)
genres
averageScore
meanScore
popularity
status
format
season
seasonYear
episodes
duration
startDate { year month day }
endDate { year month day }
studios(isMain: true) { nodes { name } }
coverImage { large }
bannerImage
externalLinks { site url type color }
}
}
`;
async function fetchMediaDetail(mediaId) {
const cfg = loadHubConfig();
const res = await fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(cfg?.accessToken ? { Authorization: `Bearer ${cfg.accessToken}` } : {})
},
body: JSON.stringify({ query: MEDIA_DETAIL_QUERY, variables: { id: mediaId } })
});
const json = await res.json();
if (json.errors) throw new Error(json.errors.map((e) => e.message).join(", "));
return json.data.Media;
}
// Streaming platforms we actually know how to route into our own
// embedded "Streamen" browser tab with MAL-Sync tracking active (site
// keys map 1:1 to MAL-Sync's supported page list). Anything AniList
// reports outside this set still gets a working link, just opened in the
// user's default system browser instead (shell.openExternal) since we
// can't guarantee MAL-Sync tracks it inside our own window.
const TRACKED_STREAMING_SITES = new Set([
"crunchyroll.com",
"netflix.com",
"hidive.com",
"hulu.com",
"animationdigitalnetwork.com",
"bilibili.tv",
"iq.com",
"disneyplus.com",
"primevideo.com",
"amazon.com"
]);
function isTrackedHost(url) {
try {
const host = new URL(url).hostname.replace(/^www\./, "");
return [...TRACKED_STREAMING_SITES].some((h) => host === h || host.endsWith("." + h));
} catch {
return false;
}
}
// Runs in the MAIN process (full Node + network access, no renderer
// sandbox/CSP quirks to fight) and is exposed to the dashboard view via
// ipcMain.handle below - the standard, reliable Electron pattern for
// renderer-to-network calls.
async function fetchWatching() {
const cfg = loadHubConfig();
if (!cfg || !cfg.accessToken || !cfg.userId) {
return { loggedIn: false };
}
const res = await fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${cfg.accessToken}`
},
body: JSON.stringify({ query: WATCHING_QUERY, variables: { userId: cfg.userId } })
});
const json = await res.json();
const collection = json.data?.MediaListCollection;
const entries = (collection?.lists || []).flatMap((l) => l.entries);
entries.sort((a, b) => b.updatedAt - a.updatedAt);
// Attach only the REAL streaming availability for each entry (AniList's
// own externalLinks with type STREAMING) - this is what lets the
// dashboard show "only where it's actually available" instead of a
// fixed site picker, and lets us jump straight to the show's real page
// instead of a generic site search.
for (const e of entries) {
const links = (e.media.externalLinks || []).filter((l) => l.type === "STREAMING");
e.streamingLinks = links.map((l) => ({
site: l.site,
url: l.url,
color: l.color,
icon: l.icon,
trackable: isTrackedHost(l.url)
}));
}
// scoreFormat tells the dashboard how to render/collect the score input
// (POINT_5 = 0-5 stars, POINT_10 = 0-10, POINT_100 = 0-100, etc.) - the
// raw `score` field on each entry is ALREADY in this format, no
// conversion needed as long as we send updates back in the same format.
const scoreFormat = collection?.user?.mediaListOptions?.scoreFormat || "POINT_10";
return { loggedIn: true, userName: cfg.userName, entries, scoreFormat };
}
const SEARCH_QUERY = `
query ($search: String) {
Page(page: 1, perPage: 10) {
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
id
title { romaji english }
episodes
status
coverImage { medium }
mediaListEntry { id status progress }
}
}
}
`;
const ADD_MUTATION = `
mutation ($mediaId: Int, $status: MediaListStatus) {
SaveMediaListEntry(mediaId: $mediaId, status: $status) {
id
status
progress
}
}
`;
async function anilistRequest(query, variables) {
const cfg = loadHubConfig();
if (!cfg || !cfg.accessToken) {
throw new Error("Nicht eingeloggt - Hub-Server-Login fehlt.");
}
const res = await fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${cfg.accessToken}`
},
body: JSON.stringify({ query, variables })
});
const json = await res.json();
if (json.errors) {
throw new Error(json.errors.map((e) => e.message).join(", "));
}
return json.data;
}
async function searchAnime(search) {
const data = await anilistRequest(SEARCH_QUERY, { search });
return data.Page.media;
}
// status: "PLANNING" | "CURRENT" | ... (AniList MediaListStatus enum)
async function addAnimeToList(mediaId, status = "PLANNING") {
const data = await anilistRequest(ADD_MUTATION, { mediaId, status });
return data.SaveMediaListEntry;
}
const UPDATE_MUTATION = `
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus, $score: Float) {
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status, score: $score) {
id
progress
status
score
}
}
`;
// Partial update: only fields the caller actually passed get sent to
// AniList (undefined fields are omitted from the GraphQL variables so
// they don't get overwritten with null) - lets the dashboard update just
// the episode count, just the score, or just the status independently.
async function updateAnimeEntry(mediaId, { progress, status, score } = {}) {
const variables = { mediaId };
if (progress !== undefined) variables.progress = progress;
if (status !== undefined) variables.status = status;
if (score !== undefined) variables.score = score;
const data = await anilistRequest(UPDATE_MUTATION, variables);
return data.SaveMediaListEntry;
}
// Maps a streaming URL's hostname to one of our persistent tab views, so
// "watch-now" can switch to the RIGHT tab (Crunchyroll link -> Crunchyroll
// tab, Netflix link -> Netflix tab, etc.) instead of always reusing one.
function siteIdForUrl(url) {
try {
const host = new URL(url).hostname.replace(/^www\./, "");
if (host.endsWith("crunchyroll.com")) return "crunchyroll";
if (host.endsWith("netflix.com")) return "netflix";
if (host.endsWith("disneyplus.com")) return "disneyplus";
return null;
} catch {
return null;
}
}
ipcMain.handle("fetch-watching", () => fetchWatching());
ipcMain.handle("search-anime", (event, search) => searchAnime(search));
ipcMain.handle("add-anime", (event, mediaId, status) => addAnimeToList(mediaId, status));
ipcMain.handle("update-anime", (event, mediaId, fields) => updateAnimeEntry(mediaId, fields));
ipcMain.handle("fetch-media-detail", (event, mediaId) => fetchMediaDetail(mediaId));
// "Schauen" button on a dashboard card: opens the REAL streaming URL for
// that exact show (from AniList's own externalLinks), not a site search.
// If it's one of our own tabbed sites (Crunchyroll/Netflix/Disney+), we
// switch to that tab and navigate it there so MAL-Sync can track it;
// otherwise open in the user's default browser.
ipcMain.handle("watch-now", (event, { url, trackable }) => {
const siteId = siteIdForUrl(url);
if (trackable && siteId && streamingViews[siteId]) {
streamingViews[siteId].webContents.loadURL(url);
activeTab = siteId;
layoutViews();
// Keep the tab bar's own highlighted tab in sync, since this switch
// was triggered from the dashboard view, not a click on the tab bar.
tabBarView.webContents.send("activate-tab", siteId);
return { ok: true, opened: siteId };
}
shell.openExternal(url);
return { ok: true, opened: "external" };
});
let mainWindow;
let dashboardView;
let tabBarView;
let browserView;
let activeTab = "dashboard";
// Multiple streaming site tabs, each a persistent BrowserView so
// switching between Crunchyroll/Netflix/Disney+ keeps its own login
// session, scroll position and playback state instead of reloading.
const STREAMING_SITES = [
{ id: "crunchyroll", label: "Crunchyroll", url: "https://www.crunchyroll.com" },
{ id: "netflix", label: "Netflix", url: "https://www.netflix.com" },
{ id: "disneyplus", label: "Disney+", url: "https://www.disneyplus.com" }
];
const streamingViews = {}; // id -> BrowserView
function layoutViews() {
if (!mainWindow) return;
const [width, height] = mainWindow.getContentSize();
const tabBarHeight = 44;
const bounds = { x: 0, y: tabBarHeight, width, height: height - tabBarHeight };
const hidden = { x: 0, y: 0, width: 0, height: 0 };
dashboardView.setBounds(activeTab === "dashboard" ? bounds : hidden);
for (const [id, view] of Object.entries(streamingViews)) {
view.setBounds(activeTab === id ? bounds : hidden);
}
}
const WINDOW_STATE_PATH = path.join(os.homedir(), ".anilist-tracker", "window-state.json");
function loadWindowState() {
try {
return JSON.parse(fs.readFileSync(WINDOW_STATE_PATH, "utf8"));
} catch {
return { width: 1280, height: 860 }; // first-launch default
}
}
function saveWindowState(win) {
if (!win || win.isDestroyed()) return;
const bounds = win.isMaximized() || win.isFullScreen() ? win.getNormalBounds() : win.getBounds();
const state = {
...bounds,
isMaximized: win.isMaximized(),
isFullScreen: win.isFullScreen()
};
try {
fs.mkdirSync(path.dirname(WINDOW_STATE_PATH), { recursive: true });
fs.writeFileSync(WINDOW_STATE_PATH, JSON.stringify(state));
} catch (err) {
console.error("Failed to save window state:", err.message);
}
}
async function createWindow() {
// Load the real MAL-Sync extension into this app's own session so its
// background service worker + content scripts run against pages opened
// in browserView below.
try {
await session.defaultSession.loadExtension(MALSYNC_EXTENSION_PATH, {
allowFileAccess: true
});
console.log("MAL-Sync extension loaded into app session");
} catch (err) {
console.error("Failed to load MAL-Sync extension:", err.message);
}
mainWindow = new BrowserWindow({
...loadWindowState(),
title: "AniList Companion",
webPreferences: {
preload: path.join(__dirname, "tabbar-preload.js"),
contextIsolation: true
}
});
if (loadWindowState().isMaximized) mainWindow.maximize();
if (loadWindowState().isFullScreen) mainWindow.setFullScreen(true);
// Debounced save so dragging/resizing doesn't hammer disk writes -
// only the LAST bounds after the user stops moving/resizing gets saved.
let saveStateTimer = null;
const scheduleSaveState = () => {
clearTimeout(saveStateTimer);
saveStateTimer = setTimeout(() => saveWindowState(mainWindow), 400);
};
mainWindow.on("move", scheduleSaveState);
mainWindow.on("resize", scheduleSaveState);
mainWindow.on("close", () => saveWindowState(mainWindow));
// Thin tab bar at the top (dashboard vs. streaming-with-tracking),
// rendered as its own tiny BrowserView so it stays visible regardless
// of what the browser tab is doing.
tabBarView = new BrowserView({
webPreferences: {
preload: path.join(__dirname, "tabbar-preload.js"),
contextIsolation: true
}
});
mainWindow.addBrowserView(tabBarView);
tabBarView.setBounds({ x: 0, y: 0, width: 1280, height: 44 });
tabBarView.setAutoResize({ width: true });
tabBarView.webContents.loadFile(path.join(__dirname, "tabbar.html"));
dashboardView = new BrowserView({
webPreferences: {
preload: path.join(__dirname, "dashboard-preload.js"),
contextIsolation: true
}
});
mainWindow.addBrowserView(dashboardView);
dashboardView.webContents.loadFile(path.join(__dirname, "dashboard.html"));
// One persistent BrowserView per streaming site, each loaded once at
// startup and kept alive when switching tabs (so login sessions,
// scroll position, and any in-progress video keep playing in the
// background instead of being torn down every tab switch).
for (const site of STREAMING_SITES) {
const view = new BrowserView({
webPreferences: { contextIsolation: true }
});
mainWindow.addBrowserView(view);
view.webContents.loadURL(site.url);
// Network traffic capture (CDP), used to discover streaming sites'
// real internal API endpoints/IDs for a more precise AniList
// mapping. Only observes traffic this app's own embedded browser
// makes - not a system-wide proxy, doesn't touch other apps.
setupTrafficCapture(view.webContents);
streamingViews[site.id] = view;
}
browserView = streamingViews.crunchyroll; // default target for watch-now IPC
layoutViews();
mainWindow.on("resize", () => {
const [width] = mainWindow.getContentSize();
tabBarView.setBounds({ x: 0, y: 0, width, height: 44 });
layoutViews();
});
// IPC-free tab switching: the tab bar page posts a message via
// executeJavaScript-triggered navigation event we listen for.
tabBarView.webContents.on("ipc-message", (event, channel, tab) => {
if (channel === "switch-tab") {
activeTab = tab;
layoutViews();
if (tab === "dashboard") dashboardView.webContents.send("refresh");
}
});
mainWindow.on("closed", () => {
mainWindow = null;
});
}
const TRAFFIC_LOG_PATH = path.join(os.homedir(), ".anilist-tracker", "cr-traffic.jsonl");
// Only log requests to hosts that are actually part of Crunchyroll's own
// API surface - excludes ads/analytics/CDN noise so the log stays
// readable and focused on discovering their internal show/episode IDs.
const CR_API_HOST_PATTERN = /(^|\.)crunchyroll\.com$/i;
function setupTrafficCapture(webContents) {
const dbg = webContents.debugger;
try {
dbg.attach("1.3");
} catch (err) {
console.error("Traffic capture: failed to attach debugger:", err.message);
return;
}
dbg.sendCommand("Network.enable").catch(() => {});
const pendingRequests = new Map(); // requestId -> { url, method }
dbg.on("message", (event, method, params) => {
if (method === "Network.requestWillBeSent") {
const url = params.request?.url || "";
let host;
try {
host = new URL(url).hostname;
} catch {
return;
}
if (!CR_API_HOST_PATTERN.test(host)) return;
// Only care about actual API/data calls, not asset loads
// (images/fonts/scripts) - keeps the log to the interesting stuff.
if (/\.(jpg|jpeg|png|gif|svg|woff2?|ttf|css|js|ico)(\?|$)/i.test(url)) return;
pendingRequests.set(params.requestId, {
url,
method: params.request.method,
postData: params.request.postData || null
});
} else if (method === "Network.responseReceived") {
const pending = pendingRequests.get(params.requestId);
if (!pending) return;
pending.status = params.response.status;
pending.mimeType = params.response.mimeType;
} else if (method === "Network.loadingFinished") {
const pending = pendingRequests.get(params.requestId);
if (!pending) return;
pendingRequests.delete(params.requestId);
// Fetch the actual response body via CDP, then append a structured
// record to the traffic log. Errors here (body already discarded,
// etc.) are swallowed - we still keep the request metadata.
dbg
.sendCommand("Network.getResponseBody", { requestId: params.requestId })
.then((body) => {
appendTrafficRecord({ ...pending, responseBody: body.body, base64: body.base64Encoded });
})
.catch(() => {
appendTrafficRecord(pending);
});
}
});
webContents.on("destroyed", () => {
try {
dbg.detach();
} catch {}
});
}
function appendTrafficRecord(record) {
try {
fs.mkdirSync(path.dirname(TRAFFIC_LOG_PATH), { recursive: true });
fs.appendFileSync(
TRAFFIC_LOG_PATH,
JSON.stringify({ time: new Date().toISOString(), ...record }) + "\n"
);
} catch (err) {
console.error("Traffic capture: failed to write log:", err.message);
}
// Crunchyroll's series-level CMS object (content/v2/cms/objects/<id>)
// is the most reliable source of the real series title for a bare
// series_id - populate our id->title cache whenever we observe one,
// so later up_next events (which usually just have the episode title)
// can resolve back to the correct series name.
if (record.url && record.url.includes("/content/v2/cms/objects/") && record.responseBody) {
try {
const json = JSON.parse(record.responseBody);
for (const item of json.data || []) {
if (item.id && item.title && item.type === "series") {
seriesIdToTitle.set(item.id, item.title);
}
}
} catch {
// ignore parse failures, other capture paths still work
}
}
// Real-time extraction: Crunchyroll's own content/v2/discover/up_next
// API returns an authoritative "identifier" string in the shape
// "<seriesId>|<seasonId>|E<episodeNumber>" plus a human-readable
// slug_title and title - this is far more precise than scraping page
// text (it's literally what Crunchyroll's own frontend uses), so we
// feed it straight into the hub server as a structured event instead
// of relying on the DOM-scraping content script for this site.
if (record.url && record.url.includes("/content/v2/discover/up_next/") && record.responseBody) {
try {
const json = JSON.parse(record.responseBody);
const panel = json.data?.[0]?.panel;
const meta = panel?.episode_metadata;
if (panel && meta?.identifier) {
const [seriesId, seasonId, episodeTag] = meta.identifier.split("|");
const episodeNumber = meta.episode_number ?? parseInt((episodeTag || "").replace(/^E/, ""), 10);
reportCrunchyrollApiEvent({
seriesId,
seasonId,
episodeNumber,
episodeTitle: panel.title,
seriesTitle: meta.season_title || panel.title
});
}
} catch {
// Not JSON, or shape changed - ignore, other capture paths still work.
}
}
}
// Crunchyroll's series-level CMS object (content/v2/cms/objects/<id>)
// gives us the real series title for a bare series_id, since up_next
// only reliably has the EPISODE title. Cache id->title as we observe it.
const seriesIdToTitle = new Map();
function reportCrunchyrollApiEvent({ seriesId, seasonId, episodeNumber, episodeTitle, seriesTitle }) {
const knownTitle = seriesIdToTitle.get(seriesId);
const title = knownTitle || seriesTitle || episodeTitle;
if (!title || episodeNumber == null) return;
fetch("http://localhost:9632/event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceId: "app-cr-api",
rawTitle: title,
episode: episodeNumber,
force: false
})
}).catch(() => {});
}
app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
+10
View File
@@ -0,0 +1,10 @@
const { contextBridge, ipcRenderer } = require("electron");
// Note: this BrowserView's webContents fires an "ipc-message" event in
// the main process whenever ipcRenderer.send() is called here - NOT
// sendToHost (that API only exists for the legacy <webview> tag).
contextBridge.exposeInMainWorld("tabbar", {
switchTab: (tab) => ipcRenderer.send("switch-tab", tab),
navigate: (url) => ipcRenderer.send("navigate", url),
onActivateTab: (cb) => ipcRenderer.on("activate-tab", (event, tab) => cb(tab))
});
+83
View File
@@ -0,0 +1,83 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #14141c;
-webkit-app-region: drag;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
display: flex;
align-items: center;
padding: 0 12px 0 84px;
gap: 4px;
user-select: none;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.tab {
-webkit-app-region: no-drag;
padding: 8px 16px;
border-radius: 8px;
color: #8a8a9a;
font-size: 13px;
font-weight: 600;
cursor: pointer;
background: transparent;
border: none;
display: flex;
align-items: center;
gap: 7px;
transition: background .15s, color .15s;
}
.tab .swatch {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.tab:hover { background: rgba(255,255,255,0.05); color: #ccc; }
.tab.active { background: rgba(124,92,255,0.16); color: #fff; }
.tab.active .swatch { box-shadow: 0 0 6px currentColor; }
.spacer { flex: 1; }
</style>
</head>
<body>
<button class="tab active" id="tab-dashboard"><span class="swatch" style="background:#7c5cff;color:#7c5cff"></span>Meine Liste</button>
<button class="tab" data-tab="crunchyroll"><span class="swatch" style="background:#f47521;color:#f47521"></span>Crunchyroll</button>
<button class="tab" data-tab="netflix"><span class="swatch" style="background:#e50914;color:#e50914"></span>Netflix</button>
<button class="tab" data-tab="disneyplus"><span class="swatch" style="background:#1a76d1;color:#1a76d1"></span>Disney+</button>
<div class="spacer"></div>
<script>
const allTabButtons = () => document.querySelectorAll(".tab");
function activateTabButton(btn) {
allTabButtons().forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
}
document.getElementById("tab-dashboard").addEventListener("click", (e) => {
activateTabButton(e.currentTarget);
window.tabbar.switchTab("dashboard");
});
document.querySelectorAll(".tab[data-tab]").forEach((btn) => {
btn.addEventListener("click", (e) => {
activateTabButton(e.currentTarget);
window.tabbar.switchTab(btn.dataset.tab);
});
});
// The dashboard's "Anschauen" buttons can switch tabs directly (main
// process), so reflect that here too - otherwise the tab bar would
// still show "Meine Liste" as active while a streaming tab is shown.
window.tabbar.onActivateTab((tab) => {
const btn = tab === "dashboard"
? document.getElementById("tab-dashboard")
: document.querySelector(`.tab[data-tab="${tab}"]`);
if (btn) activateTabButton(btn);
});
</script>
</body>
</html>