Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5ec86222 | ||
|
|
d80a5255ed | ||
|
|
89c83ee5de | ||
|
|
69b5637bd6 | ||
|
|
d3192f1843 | ||
|
|
51749e05db | ||
|
|
5a5694f200 | ||
|
|
50d6c42207 | ||
|
|
bb1a938cc0 | ||
|
|
67c7ca8603 | ||
|
|
fc0293029d | ||
|
|
eed42a260a | ||
|
|
61b14e8f65 |
@@ -35,9 +35,14 @@ class InvalidUrlError(Exception):
|
||||
# reviewers catch drift.
|
||||
_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||
("patreon", re.compile(
|
||||
# Three creator URL shapes — bare (patreon.com/Atole), `c/`, and `cw/`
|
||||
# (the "creator workspace" URL served once subscribed, see
|
||||
# patreon_resolver._VANITY_RE). A trailing sub-path is allowed so a
|
||||
# creator's inner page still derives the slug. Nav pages stay excluded.
|
||||
r"^https?://(?:www\.)?patreon\.com/"
|
||||
r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)"
|
||||
r"(?P<slug>[^/?#]+)/?$",
|
||||
r"(?:cw/|c/)?"
|
||||
r"(?!(?:home|search|messages|notifications|library|settings|posts)(?:[/?#]|$))"
|
||||
r"(?P<slug>[^/?#]+)",
|
||||
re.IGNORECASE,
|
||||
)),
|
||||
("subscribestar", re.compile(
|
||||
|
||||
@@ -31,6 +31,69 @@ browser.runtime.onInstalled.addListener(() => ensureInitialized());
|
||||
browser.runtime.onStartup.addListener(() => ensureInitialized());
|
||||
ensureInitialized().catch(e => console.error('init failed:', e));
|
||||
|
||||
// ---- Extension self-update check (#1489) ----
|
||||
// Installed per-instance from the operator's FC host, so Firefox's static
|
||||
// update_url can't apply (each instance has a different host). Instead ask the
|
||||
// configured backend for the latest published version and nudge the operator to
|
||||
// reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
|
||||
// a toolbar badge (daily). /api/extension/manifest is public and returns
|
||||
// {version, latest_url, sha256}; the XPI is served from the web root (not /api).
|
||||
|
||||
function versionIsNewer(candidate, current) {
|
||||
// Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't).
|
||||
const a = String(candidate).split('.').map(n => parseInt(n, 10) || 0);
|
||||
const b = String(current).split('.').map(n => parseInt(n, 10) || 0);
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function checkForUpdateInfo() {
|
||||
await ensureInitialized();
|
||||
if (!api.isConfigured()) return { updateAvailable: false, configured: false };
|
||||
let info;
|
||||
try {
|
||||
info = await api.getExtensionManifest();
|
||||
} catch (e) {
|
||||
return { updateAvailable: false, error: e.message };
|
||||
}
|
||||
const currentVersion = browser.runtime.getManifest().version;
|
||||
const latestVersion = info && info.version ? info.version : null;
|
||||
// latest_url is served from the web root; strip the /api suffix off baseUrl
|
||||
// (same transform as OPEN_ARTIST_PAGE).
|
||||
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
|
||||
return {
|
||||
updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshUpdateBadge() {
|
||||
let r;
|
||||
try { r = await checkForUpdateInfo(); } catch { return; }
|
||||
try {
|
||||
await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
|
||||
if (r.updateAvailable) {
|
||||
await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' });
|
||||
await browser.action.setTitle({ title: `FabledCurator — update available (v${r.latestVersion})` });
|
||||
} else {
|
||||
await browser.action.setTitle({ title: 'FabledCurator' });
|
||||
}
|
||||
} catch { /* action API unavailable — non-fatal */ }
|
||||
}
|
||||
|
||||
// Daily proactive check (needs the "alarms" permission). create() is idempotent
|
||||
// by name, so re-running it on each event-page load is safe.
|
||||
browser.alarms.create('fc-update-check', { periodInMinutes: 24 * 60, delayInMinutes: 1 });
|
||||
browser.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === 'fc-update-check') refreshUpdateBadge();
|
||||
});
|
||||
browser.runtime.onStartup.addListener(() => refreshUpdateBadge());
|
||||
browser.runtime.onInstalled.addListener(() => refreshUpdateBadge());
|
||||
|
||||
// ---- Discord token capture via webRequest ----
|
||||
|
||||
browser.webRequest.onBeforeSendHeaders.addListener(
|
||||
@@ -298,6 +361,9 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
}
|
||||
}
|
||||
|
||||
case 'CHECK_UPDATE':
|
||||
return await checkForUpdateInfo();
|
||||
|
||||
default:
|
||||
return { error: `Unknown message type: ${msg.type}` };
|
||||
}
|
||||
|
||||
@@ -89,6 +89,12 @@ class FabledCuratorAPI {
|
||||
const qs = new URLSearchParams({ url }).toString();
|
||||
return this.request('GET', `/extension/probe?${qs}`);
|
||||
}
|
||||
// Latest published extension version on this instance — drives the in-app
|
||||
// update prompt. Public endpoint (no key needed, but request() sends it
|
||||
// harmlessly). Returns {version, xpi_url, latest_url, sha256}.
|
||||
getExtensionManifest() {
|
||||
return this.request('GET', '/extension/manifest');
|
||||
}
|
||||
|
||||
// Connection test = the cheapest read with auth.
|
||||
testConnection() {
|
||||
|
||||
@@ -86,7 +86,16 @@ const PLATFORMS = {
|
||||
* script to decide whether to show the floating "Add as source" button.
|
||||
*/
|
||||
const PLATFORM_ARTIST_PATTERNS = {
|
||||
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c\/)[^/?#]+\/?$/i,
|
||||
// Patreon serves the same creator under three URL shapes (see backend
|
||||
// patreon_resolver._VANITY_RE): bare `patreon.com/Atole`, `c/` prefix, and
|
||||
// `cw/` "creator workspace" — the last is the URL you land on once you're
|
||||
// SUBSCRIBED, which is exactly when the button matters. Match all three, and
|
||||
// drop the single-segment end-anchor so a creator's inner page
|
||||
// (…/cw/Atole/posts, …/Atole/membership) also injects the button. Nav pages
|
||||
// (home/search/…/posts permalink) stay excluded. Mirrors extension_service
|
||||
// ._PLATFORM_PATTERNS — keep in sync (operator-flagged 2026-07-13: button
|
||||
// vanished once subscribed because the old pattern only matched the bare root).
|
||||
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i,
|
||||
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
|
||||
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
|
||||
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FabledCurator",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.9",
|
||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||
|
||||
"browser_specific_settings": {
|
||||
@@ -22,7 +22,8 @@
|
||||
"tabs",
|
||||
"activeTab",
|
||||
"webRequest",
|
||||
"webRequestBlocking"
|
||||
"webRequestBlocking",
|
||||
"alarms"
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fabledcurator-extension",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"description": "Firefox extension for FabledCurator",
|
||||
"scripts": {
|
||||
@@ -10,6 +10,6 @@
|
||||
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
||||
},
|
||||
"devDependencies": {
|
||||
"web-ext": "^8.0.0"
|
||||
"web-ext": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,17 @@ body {
|
||||
.btn.block { display: block; width: 100%; margin-top: 8px; }
|
||||
.btn.link { background: none; color: var(--on-surface-variant); padding: 4px; }
|
||||
.btn.link:hover { color: var(--accent); }
|
||||
.btn.small { padding: 6px 12px; font-size: 13px; }
|
||||
|
||||
/* In-app update prompt (accent-tinted so it reads as an actionable notice). */
|
||||
.update-banner {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 10px 10px 0; padding: 10px 12px;
|
||||
background: rgba(244, 186, 122, 0.12);
|
||||
border: 1px solid rgba(244, 186, 122, 0.4);
|
||||
border-radius: 6px;
|
||||
}
|
||||
#update-text { flex: 1; font-size: 13px; }
|
||||
|
||||
.source-row .play {
|
||||
background: none; border: none; color: var(--on-surface-variant);
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
</section>
|
||||
|
||||
<section id="main-content" class="main hidden">
|
||||
<div id="update-banner" class="update-banner hidden">
|
||||
<span id="update-text"></span>
|
||||
<button id="update-btn" class="btn primary small">Update</button>
|
||||
</div>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="platforms">Platforms</button>
|
||||
<button class="tab" data-tab="sources">Sources</button>
|
||||
|
||||
@@ -14,6 +14,7 @@ async function init() {
|
||||
setupEventListeners();
|
||||
showPlatformsLoading();
|
||||
testConnectionIfNeeded();
|
||||
checkForUpdate();
|
||||
loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`));
|
||||
} catch (e) {
|
||||
showSetupRequired();
|
||||
@@ -63,6 +64,26 @@ function updateConnectionDot(connected) {
|
||||
d.title = connected ? 'Connected to FabledCurator' : 'Disconnected';
|
||||
}
|
||||
|
||||
// Nudge to reinstall when the configured instance publishes a newer signed XPI
|
||||
// (the extension is self-hosted, so there's no Firefox auto-update). Never
|
||||
// blocks the popup — a failed check just leaves the banner hidden.
|
||||
async function checkForUpdate() {
|
||||
try {
|
||||
const r = await browser.runtime.sendMessage({ type: 'CHECK_UPDATE' });
|
||||
if (r && r.updateAvailable && r.xpiUrl) showUpdateBanner(r);
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
function showUpdateBanner(r) {
|
||||
document.getElementById('update-text').textContent =
|
||||
`Update available — v${r.latestVersion} (installed v${r.currentVersion})`;
|
||||
// Opening the signed XPI triggers Firefox's native install prompt.
|
||||
document.getElementById('update-btn').addEventListener('click', () => {
|
||||
browser.tabs.create({ url: r.xpiUrl });
|
||||
});
|
||||
document.getElementById('update-banner').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function loadPlatformStatus() {
|
||||
const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
|
||||
const c = document.getElementById('platforms-list');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<header class="fc-topnav">
|
||||
<header ref="navEl" class="fc-topnav" :class="{ 'fc-topnav--chrome': hasStickyChrome }">
|
||||
<div class="fc-nav-left">
|
||||
<RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home">
|
||||
<img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" />
|
||||
@@ -64,13 +64,39 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import router, { FRONT_DOOR } from '../router.js'
|
||||
import { useSystemStore } from '../stores/system.js'
|
||||
import PipelineStatusChip from './PipelineStatusChip.vue'
|
||||
|
||||
const system = useSystemStore()
|
||||
onMounted(() => system.refreshHealth())
|
||||
|
||||
// Publish the nav's REAL height as --fc-nav-h so full-height workspaces
|
||||
// (Explore/Subscriptions) and sticky sub-headers pin to it exactly instead of a
|
||||
// hardcoded 64px that Vuetify 4's MD3 sizing broke — the Explore breadcrumb was
|
||||
// tucking under a taller nav (#1481). ResizeObserver keeps it live as the nav
|
||||
// reflows (per-view teleported actions, mobile breakpoint, chip state changes).
|
||||
const navEl = ref(null)
|
||||
let navRO = null
|
||||
onMounted(() => {
|
||||
system.refreshHealth()
|
||||
if (navEl.value && 'ResizeObserver' in window) {
|
||||
navRO = new ResizeObserver(() => {
|
||||
const h = navEl.value?.offsetHeight
|
||||
if (h) document.documentElement.style.setProperty('--fc-nav-h', `${h}px`)
|
||||
})
|
||||
navRO.observe(navEl.value)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => { navRO?.disconnect() })
|
||||
|
||||
// Views that pin a sticky sub-header (filter bar / tabs) directly under the nav
|
||||
// declare `meta.stickyChrome`. On those, the nav doesn't fade to transparent at
|
||||
// its bottom — it hands off at the shared seam alpha so the sub-header can
|
||||
// continue the SAME fade (see .fc-chrome-continues in app.css). One gradient.
|
||||
const route = useRoute()
|
||||
const hasStickyChrome = computed(() => !!route.meta?.stickyChrome)
|
||||
|
||||
// Every route with a meta.title is a nav entry. Order by meta.navOrder —
|
||||
// router.getRoutes() does NOT guarantee declaration order, so explicit numbers
|
||||
@@ -119,16 +145,35 @@ const health = computed(() => {
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
/* Obsidian (#14171A = 20,23,26) gradient fade — content scrolls under it. */
|
||||
/* Obsidian (#14171A) fade — content scrolls under it. Holds high (0.92 →
|
||||
0.84) through the top half, then eases to transparent over the bottom
|
||||
quarter so it tails off softly instead of a straight line to a hard edge
|
||||
(operator 2026-07-13). Shared --fc-chrome-rgb keeps it in sync with the
|
||||
sub-header continuation. */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(20, 23, 26, 0.92) 0%,
|
||||
rgba(20, 23, 26, 0.65) 60%,
|
||||
rgba(20, 23, 26, 0) 100%
|
||||
rgba(var(--fc-chrome-rgb), 0.92) 0%,
|
||||
rgba(var(--fc-chrome-rgb), 0.84) 50%,
|
||||
rgba(var(--fc-chrome-rgb), 0.55) 75%,
|
||||
rgba(var(--fc-chrome-rgb), 0) 100%
|
||||
);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
/* On a view with a sticky sub-header pinned beneath (meta.stickyChrome), the nav
|
||||
stops fading at the shared seam alpha instead of going fully transparent — the
|
||||
sub-header (.fc-chrome-continues) picks the fade up from there, so the two read
|
||||
as one continuous gradient. Compound selector out-specifies .fc-topnav so it
|
||||
wins regardless of Vite's production CSS ordering. --fc-chrome-* come from the
|
||||
global :root in app.css (custom props inherit into scoped styles). */
|
||||
.fc-topnav.fc-topnav--chrome {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--fc-chrome-rgb), 0.92) 0%,
|
||||
rgba(var(--fc-chrome-rgb), 0.84) 60%,
|
||||
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.fc-brand {
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="fc-filterbar-wrap">
|
||||
<div class="fc-filterbar-wrap fc-chrome-continues">
|
||||
<div class="fc-filterbar">
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
@@ -306,27 +306,17 @@ function pushFilter(mutate) {
|
||||
frosted block pinned directly under the 64px TopNav and continuous with it. */
|
||||
.fc-filterbar-wrap {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||
z-index: 5;
|
||||
/* Attach to the TopNav: cancel the v-container's top padding (pt-2 = 8px)
|
||||
so the bar sits flush at 64px even at scroll 0 — without this it detaches
|
||||
and a gap shows through when scrolled to the top. */
|
||||
margin-top: -8px;
|
||||
margin-bottom: 12px;
|
||||
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so
|
||||
the two read as one continuous piece of chrome — images scroll visibly
|
||||
under both. The nav's gradient fades to transparent at ITS bottom; this
|
||||
bar re-darkens at its top, so a faint seam (the page/image showing through
|
||||
the nav's transparent edge) separates them when scrolled to the very top,
|
||||
while under-scroll they frost as one. */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(20, 23, 26, 0.92) 0%,
|
||||
rgba(20, 23, 26, 0.65) 60%,
|
||||
rgba(20, 23, 26, 0) 100%
|
||||
);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
/* The frost itself (obsidian fade + blur) is the shared .fc-chrome-continues
|
||||
primitive: it CONTINUES the TopNav's fade from the seam alpha to transparent
|
||||
rather than re-darkening, so the nav + bar read as one gradient (operator
|
||||
2026-07-13). This block only owns the sticky positioning now. */
|
||||
}
|
||||
.fc-filterbar {
|
||||
display: flex;
|
||||
@@ -341,6 +331,20 @@ function pushFilter(mutate) {
|
||||
.fc-filterbar-wrap :deep(.v-btn-group) {
|
||||
background-color: rgba(20, 23, 26, 0.72);
|
||||
}
|
||||
/* Media toggle (All / Images / Videos) as ONE cohesive segmented control.
|
||||
FC's global VBtn { rounded: 'pill' } default made Vuetify 4 pill-round each
|
||||
SEGMENT individually, so the rounded ends collided at the joins — the shapes
|
||||
landed awkwardly on the button edges (operator 2026-07-13). Square the inner
|
||||
segments (over the pill utility's !important) and clip the group to a single
|
||||
8px outline (matches the chips/tiles rounding elsewhere in the app). Radius
|
||||
only — no height change, so the bar height and nav offset are untouched. */
|
||||
.fc-filterbar-wrap :deep(.v-btn-toggle) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-filterbar-wrap :deep(.v-btn-toggle .v-btn) {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.fc-filterbar__search { max-width: 320px; min-width: 200px; }
|
||||
.fc-filterbar__chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
/* The tag chips' bodies toggle include/exclude — signal they're clickable. */
|
||||
|
||||
@@ -20,7 +20,7 @@ const routes = [
|
||||
|
||||
// FC-2: image backbone
|
||||
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } },
|
||||
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20 } },
|
||||
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20, stickyChrome: true } },
|
||||
// Explore: a 3-pane tagging workspace — walk an image's visual neighbours
|
||||
// (left) while tagging the focused image (center viewer + modal-parity tag
|
||||
// rail). Optional anchor param — the bare /explore nav entry SEEDS a random
|
||||
@@ -29,11 +29,11 @@ const routes = [
|
||||
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
|
||||
// the three "browse the library by an axis" surfaces. One nav entry; the old
|
||||
// standalone paths redirect into the matching tab (below).
|
||||
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30 } },
|
||||
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30, stickyChrome: true } },
|
||||
// Artist detail — no meta.title (reached by clicking an artist, not nav).
|
||||
{ path: '/artist/:slug', name: 'artist', component: ArtistView },
|
||||
// Series browse — a nav entry (meta.title).
|
||||
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40 } },
|
||||
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40, stickyChrome: true } },
|
||||
// Series management — no meta.title (reached from a series card/tag).
|
||||
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
|
||||
// Series reader — immersive (no top nav, no meta.title).
|
||||
@@ -41,10 +41,10 @@ const routes = [
|
||||
|
||||
// FC-3: subscription backbone — purely management (sources/downloads),
|
||||
// distinct from the Browse hub.
|
||||
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50 } },
|
||||
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50, stickyChrome: true } },
|
||||
|
||||
// Settings — config, pinned to the right of the nav (TopNav special-cases it).
|
||||
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
|
||||
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings', stickyChrome: true } },
|
||||
|
||||
// The old standalone paths now redirect into the Browse hub, preserving any
|
||||
// deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The
|
||||
|
||||
@@ -49,3 +49,67 @@
|
||||
ul, ol, figure, details, summary { padding: 0; margin: 0; }
|
||||
h1, h2, h3, h4, h5, h6, p { margin: 0; }
|
||||
}
|
||||
|
||||
/* Active-tab indicator (operator-flagged 2026-07-13 in the Vuetify-4 review): v4's
|
||||
MD3 v-tab "slider" underline renders wider than the tab and floats below it. The
|
||||
active tab's TEXT is already accent-coloured (color="accent"), so drop the slider
|
||||
and mark the active tab with a subtle accent fill + rounded top — a clean,
|
||||
unambiguous highlight app-wide (Subscriptions / Browse / Settings / Series). */
|
||||
.v-tab__slider { display: none !important; }
|
||||
.v-tab[aria-selected="true"],
|
||||
.v-tab.v-tab--selected {
|
||||
background: rgb(var(--v-theme-accent) / 0.12);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
/* --- Continuous chrome fade (operator-asked 2026-07-13: "group the sub-nav as
|
||||
part of the nav and use a single gradient in them"). ------------------------
|
||||
The TopNav and any sticky sub-header pinned directly beneath it (Gallery's
|
||||
filter bar, the Browse/Series/Settings/Subscriptions tabs bars) used to each
|
||||
paint their OWN dark-to-transparent gradient (or a solid band), so the fade
|
||||
read as happening TWICE — dark, fade out, then dark again. Instead the two
|
||||
share ONE obsidian fade: the nav paints the TOP half (opaque → the seam
|
||||
alpha) and the sub-header paints the CONTINUATION (seam alpha → transparent)
|
||||
over its own height. Both reference --fc-chrome-seam, so the alphas meet
|
||||
exactly at the 64px boundary — no re-darkening, no doubling, one gradient.
|
||||
|
||||
--fc-chrome-seam is the single knob: raise it for a heavier sub-header (more
|
||||
legible tabs/controls over scrolling content), lower it for a lighter fade. */
|
||||
:root {
|
||||
--fc-chrome-rgb: 20, 23, 26; /* obsidian #14171A — matches the TopNav */
|
||||
/* Alpha where the nav hands off to the sub-header — also the "hold" level of
|
||||
the fade. The chrome stays fairly opaque (0.92 → this) through the bulk of
|
||||
its height, then drops to transparent in a small eased section at the very
|
||||
bottom (see the multi-stop gradients), so it reads as a slow falloff that
|
||||
tails off softly rather than a straight line to a hard edge (operator
|
||||
2026-07-13). Raise for heavier/more-legible chrome, lower for a lighter fade. */
|
||||
--fc-chrome-seam: 0.68;
|
||||
/* Actual TopNav height, measured live (ResizeObserver in TopNav.vue) and used
|
||||
by full-height workspaces (Explore/Subscriptions: calc(100vh - var)) and by
|
||||
every sticky sub-header pinned beneath the nav (top: var). This was a
|
||||
hardcoded 64px in ~6 places; Vuetify 4's MD3 sizing made the real nav a
|
||||
different height, so the Explore workspace overflowed and its breadcrumb
|
||||
tucked under the nav (#1481). This fallback is only used pre-measure. */
|
||||
--fc-nav-h: 64px;
|
||||
}
|
||||
/* Applied to a sticky sub-header so it continues the nav's fade instead of
|
||||
restarting it. Percentage stops so the fade always spans the element's height
|
||||
(survives the filter bar's expanding refine panel). The blur keeps tabs and
|
||||
controls legible as the fill thins toward transparent — the solid-surface
|
||||
bars it replaces had none, so it must live here. */
|
||||
.fc-chrome-continues {
|
||||
/* Continues the nav's fade: HOLDS near the seam alpha through the first ~55%
|
||||
(subtle), then eases down to transparent over the last ~45% with an
|
||||
intermediate stop so the tail is soft — no hard line at the bottom edge
|
||||
(operator 2026-07-13). Percentage stops keep the shape spanning the
|
||||
element's height (survives the filter bar's expanding refine panel). */
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 0%,
|
||||
rgba(var(--fc-chrome-rgb), 0.60) 55%,
|
||||
rgba(var(--fc-chrome-rgb), 0.28) 82%,
|
||||
rgba(var(--fc-chrome-rgb), 0) 100%
|
||||
);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
switcher and the search stay reachable no matter how far you scroll.
|
||||
Background uses the theme surface token so content scrolls cleanly
|
||||
under it (matches SettingsView's sticky tabs). -->
|
||||
<div class="fc-browse__head">
|
||||
<div class="fc-browse__head fc-chrome-continues">
|
||||
<v-container fluid class="py-0">
|
||||
<!-- Tabs and search share one row: the axis switcher on the left, the
|
||||
search field + active-scope chips on the right (operator-asked
|
||||
@@ -154,9 +154,10 @@ function clearFilter(key) {
|
||||
<style scoped>
|
||||
.fc-browse__head {
|
||||
position: sticky;
|
||||
top: 64px; /* directly under AppShell's 64px sticky TopNav */
|
||||
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||
z-index: 4;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
/* Background is the shared .fc-chrome-continues fade — it continues the nav's
|
||||
gradient instead of a solid surface band (operator 2026-07-13). */
|
||||
}
|
||||
.fc-browse__bar {
|
||||
display: flex;
|
||||
|
||||
@@ -286,10 +286,13 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
|
||||
/* Full-height workspace under the sticky top nav. */
|
||||
/* Full-height workspace under the sticky top nav. --fc-nav-h is the nav's REAL
|
||||
measured height (set by TopNav) — a hardcoded 64px here overflowed the
|
||||
viewport under Vuetify 4's taller nav and tucked the breadcrumb under it
|
||||
(#1481). Panes scroll internally, so an exact fit keeps everything on screen. */
|
||||
.fc-ex {
|
||||
display: flex; flex-direction: column;
|
||||
height: calc(100vh - 64px);
|
||||
height: calc(100vh - var(--fc-nav-h, 64px));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and
|
||||
search/sort stay reachable on a long grid. The controls can't sit
|
||||
inside v-window (it clips sticky children), so they're hoisted here. -->
|
||||
<div class="fc-series__head">
|
||||
<div class="fc-series__head fc-chrome-continues">
|
||||
<v-tabs v-model="tab" density="compact">
|
||||
<v-tab value="browse">Browse</v-tab>
|
||||
<v-tab value="suggestions">
|
||||
@@ -294,12 +294,13 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
/* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so
|
||||
content scrolls cleanly beneath it. Surface bg matches SettingsView. */
|
||||
content scrolls cleanly beneath it. Background is the shared
|
||||
.fc-chrome-continues fade — continues the nav's gradient rather than a solid
|
||||
band (operator 2026-07-13). */
|
||||
.fc-series__head {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
|
||||
z-index: 4;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.fc-series-browse__controls {
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
||||
panels pushed the tab strip out of the viewport, forcing a scroll-
|
||||
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
|
||||
tab strip lives directly under it. Background uses the theme surface
|
||||
token so it visually merges with the page rather than the
|
||||
translucent v-tabs default. -->
|
||||
tab strip lives directly under it. The .fc-chrome-continues fade
|
||||
continues the nav's gradient across the strip (operator 2026-07-13). -->
|
||||
<v-tabs
|
||||
v-model="tab" color="accent" class="mb-4"
|
||||
style="position: sticky; top: 64px; z-index: 4;
|
||||
background: rgb(var(--v-theme-surface));"
|
||||
v-model="tab" color="accent" class="mb-4 fc-chrome-continues"
|
||||
style="position: sticky; top: var(--fc-nav-h, 64px); z-index: 4;"
|
||||
>
|
||||
<v-tab value="overview">Overview</v-tab>
|
||||
<v-tab value="activity">Activity</v-tab>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
align-tabs="start"
|
||||
color="accent"
|
||||
density="compact"
|
||||
class="fc-subs-tabs"
|
||||
class="fc-subs-tabs fc-chrome-continues"
|
||||
>
|
||||
<v-tab value="subscriptions">
|
||||
<v-icon start>mdi-account-multiple-check</v-icon>
|
||||
@@ -55,15 +55,18 @@ const { tab } = useTabQuery(VALID_TABS, 'subscriptions')
|
||||
/* Fixed-height hub: the tabs (and each tab's sticky control bar) stay
|
||||
put while ONLY the tab content scrolls — previously the whole view
|
||||
scrolled instead of just the subscription list (operator-flagged
|
||||
2026-05-28). 64px = the TopNav height (AppShell .fc-content pad-top). */
|
||||
height: calc(100vh - 64px);
|
||||
2026-05-28). --fc-nav-h = the TopNav's real measured height (#1481). */
|
||||
height: calc(100vh - var(--fc-nav-h, 64px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-subs-tabs {
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
|
||||
/* Cancel the shell's pt-2 so the tabs sit flush under the 64px nav, letting
|
||||
the .fc-chrome-continues fade read as one gradient with it (operator
|
||||
2026-07-13). The fade replaces the old border-bottom separator. */
|
||||
margin-top: -8px;
|
||||
}
|
||||
.fc-subs-window {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Unit tests for ExtensionService._derive — the URL → (platform, slug)
|
||||
parser that gates the browser extension's "Add as source" button and pulls
|
||||
the creator slug on probe/add.
|
||||
|
||||
Regression cover for #1485: Patreon serves the same creator under three URL
|
||||
shapes — bare `patreon.com/Atole`, `c/`, and `cw/` (the "creator workspace"
|
||||
URL you land on once SUBSCRIBED). The button used to vanish while subscribed
|
||||
because the pattern only matched the bare root and excluded `c/`.
|
||||
|
||||
_derive is pure URL parsing (no DB / no async), so a session-less instance is
|
||||
fine to exercise directly.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.extension_service import (
|
||||
ExtensionService,
|
||||
InvalidUrlError,
|
||||
UnknownPlatformError,
|
||||
)
|
||||
|
||||
_svc = ExtensionService(None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url, slug",
|
||||
[
|
||||
# All three Patreon creator prefixes resolve to the same vanity slug.
|
||||
("https://www.patreon.com/Atole", "Atole"),
|
||||
("https://www.patreon.com/c/Atole", "Atole"),
|
||||
("https://www.patreon.com/cw/Atole", "Atole"), # subscribed-view URL
|
||||
# A creator's inner page still derives the slug (trailing sub-path).
|
||||
("https://www.patreon.com/cw/Atole/posts", "Atole"),
|
||||
("https://www.patreon.com/Atole/membership", "Atole"),
|
||||
("https://patreon.com/c/Atole", "Atole"), # bare host, no www
|
||||
],
|
||||
)
|
||||
def test_derive_patreon_creator_urls(url, slug):
|
||||
platform, got = _svc._derive(url)
|
||||
assert platform == "patreon"
|
||||
assert got == slug
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
# Patreon's own nav pages must never read as a creator slug.
|
||||
"https://www.patreon.com/home",
|
||||
"https://www.patreon.com/settings",
|
||||
"https://www.patreon.com/search",
|
||||
"https://www.patreon.com/messages",
|
||||
"https://www.patreon.com/library",
|
||||
"https://www.patreon.com/notifications",
|
||||
"https://www.patreon.com/posts/12345", # post permalink
|
||||
"https://www.patreon.com/settings/billing", # nav sub-page
|
||||
],
|
||||
)
|
||||
def test_derive_patreon_nav_pages_rejected(url):
|
||||
with pytest.raises(UnknownPlatformError):
|
||||
_svc._derive(url)
|
||||
|
||||
|
||||
def test_derive_rejects_missing_scheme():
|
||||
with pytest.raises(InvalidUrlError):
|
||||
_svc._derive("patreon.com/Atole")
|
||||
Reference in New Issue
Block a user