{% extends "base.html" %} {% import '_start_page_form.html' as startpage %} {% block styles %} {% if devinfo_importants %}{% endif %} {# The "Could be for you" hero's Details button opens this modal (see home_foryou.js's openDetails()) -- only meaningful in the new_home layout, where fyBlock/fyHero exist at all. #} {% if new_home %}{% endif %} {% endblock %} {% block content %} {# ── Dev Info banners ────────────────────────────────────────────────── "warning", "important" and "release" posts all land here (app.py's index() builds devinfo_banners from the three). "important" additionally raises the modal further down; the banner is what remains on the page after the modal has been confirmed, so the message stays visible instead of vanishing. "release" shows the announced version and a shortcut into Settings -> Updates; the full text and the changelog live on the Dev Infos page. #} {% if devinfo_banners %}
{% for w in devinfo_banners %}
{% if w.type == 'release' %} {% elif w.type == 'important' %} {% else %} {% endif %}
{% if w.type == 'important' %} {{ _('Important') }} {% elif w.type == 'release' %} {{ _('Release') }} {% endif %}
{{ w.title }}
{# A release banner stays deliberately short: the headline plus the version it announces. The text of the post and the changelog are one click away on the Dev Infos page -- a home page banner is not the place for release notes. #} {% if w.type == 'release' %}
{{ w.release_name or w.release_tag }} {% if w.release_name and w.release_tag and w.release_name != w.release_tag %} {{ w.release_tag }} {% endif %}
{# Deep link into the Updates tab: settings.js's restoreTab() reads the hash and opens that panel, so this lands the user on the update check/install controls rather than the top of Settings. #} {{ _('Go to updates') }} {{ _('Read changelog') }}
{% else %}
{{ w.body_html|safe }}
{% endif %}
{% endfor %}
{% endif %} {# ── "Important" modal ───────────────────────────────────────────────── Rendered only when there is at least one *unread* important post (app.py filters on is_read). Confirming it marks that post as read and navigates to the Dev Infos page so the full text can actually be read -- see static/devinfo_important.js. Server-rendered rather than fetched, so it is on screen with the first paint instead of after a round trip. #} {% if devinfo_importants %} {% endif %} {# The search bar is shared by both home layouts but sits in two different places: full-bleed in the sticky topstrip on the new layout, plain inside .container on the classic one. A macro instead of two copies of the same markup -- one thing to keep in step with app.js/home_2_1.js selectors. #} {# One module dashboard widget's card shell. The card flows inside its Dashboard section (drag it to reorder, close it with the "x"). Rendered exactly ONCE per widget id -- two copies would mean two elements sharing the id "dashCard-". #} {% macro dashboard_widget_card(widget) %}

{% if widget.icon_svg %}{{ widget.icon_svg|safe }}{% endif %}{{ widget.label }}

{# "ignore missing" -- a widget template that was removed/renamed after being registered (e.g. mid-upgrade) silently disappears instead of taking down the entire home page for every user. #} {% include widget.template ignore missing %}
{# Same msgid window.__HOME_I18N carries under 'dash_remove' -- the close button is otherwise built once in JS by renderOneCard() and never touched again, but a server-rendered card needs it present from the first paint. #}
{% endmacro %} {# The search bar is shared by both home layouts but sits in two different places: full-bleed in the sticky topstrip on the new layout, plain inside .container on the classic one. A macro instead of two copies of the same markup -- one thing to keep in step with app.js/home_2_1.js selectors. #} {% macro home_searchbar(show_customize=False) %} {% endmacro %} {# The sticky strip (tabs + search + the dashboard lock toggle) is now a SIBLING before .container, not a child of it -- .container's own max-width/padding used to cap the strip's width and push it 32px down from the viewport top, so it never actually reached the edges it was meant to be sticky against. `.home-topstrip-inner` re-applies that same max-width/padding as an INNER alignment column, so the tab pill and search field still line up with the rest of the page while the strip's own background/border/blur spans full width. #} {# "All in one page" stacks the old button-bar Dashboard above Discover -- the same layout the sticky topstrip's tab pill/lock/add-widget tools were built to replace, so keeping the topstrip around here would show controls for a tab switch that no longer exists. Falls through to the classic hero+searchbar block below instead, exactly like the pre-overhaul page. #} {% if new_home and not all_in_one %}
{# The tab pill only makes sense with two SEPARATE tabs to switch between. With the Dashboard switched off (Customise this page -> "Home tabs") there is only Discover; with "All in one page" both sections render stacked with nothing to switch -- either way the pill is dropped instead of showing a single, unclickable-feeling button. #} {% if dash_enabled and not all_in_one %} {# The tab picked under "Start on" is rendered FIRST -- it is the one the page opens on, and a landing tab sitting to the RIGHT of the other reads as "the wrong one is selected". static/home_2_1.js still owns which tab is actually open (showTab); this only decides the order the two buttons appear in, so its Arrow-key handling follows along by walking the DOM. #}
{% for tab in (['disc', 'dash'] if start_tab == 'disc' else ['dash', 'disc']) %} {% if tab == 'dash' %} {% else %} {% endif %} {% endfor %}
{% endif %} {# Phone-only: the search bar itself starts collapsed (see index.css's phone breakpoint) and this button expands it in place, right of the tab pill. Hidden by CSS above that breakpoint, where the full search bar is already always visible. Same glass icon as .home-search-icon -- reused, not redrawn. #} {{ home_searchbar() }} {% if dash_enabled and not all_in_one %} {# Dashboard tools (lock the arrangement, add a card) -- meaningless against "All in one page"'s button-bar Dashboard, which has neither. Dashboard-only otherwise: hidden via CSS while the Discover tab is open (see [data-home-tab-open="disc"] in index.css). Locked state (icon + title/aria-label) is toggled by static/home_panels.js against the home_dash_locked preference. #} {# Both padlocks ship; index.css shows the one that matches .is-locked, which static/home_panels.js toggles. Drawing the state the button WOULD switch to is the other convention and is the more confusing one here -- this button says what the board is, not what the click does, same as its aria-pressed. #} {# Dashboard-only, same visibility rule as the lock button above. The menu itself is built client-side by static/home_panels.js (addMenuEntries()) from data the page already has in memory -- no markup to render here. #}
{% endif %} {# Lives in the topstrip rather than only inside a pane so it is on screen no matter which tab is open (or, in "All in one page" mode, stays visible while scrolling past both) -- the Discover pane's own toolbar (further down) keeps its copy too, so opening the modal from there still works exactly as before. Same icon/handler as that one and as the classic layout's #homeCustomize, all three open the same modal (see static/start_page.js's openStartPageModal()). #}
{% endif %}
{# ── "try the new home page" ─────────────────────────────────────────── Only rendered on the classic layout, and only until this account has either dismissed it or switched over once (app.py decides, see show_new_home_promo). Not rendered at all otherwise: a banner that the client hides after the fact still ships its text to everyone and still flashes on a slow paint. The switch is per ACCOUNT. Trying the new layout does not change what anybody else sees, which is the whole reason this is an invitation and not an announcement. #} {% if show_new_home_promo %}
{{ _('Beta') }}
{{ _('Try the new home page') }}
{{ _('Rows grouped by question instead of by source, with your watch progress and watchlist on top. Only you see the change — switch back at any time in Settings.') }}
{% endif %} {# The big "What do you want to download?" headline is the CLASSIC layout's opening move. The two-tab layout above (new_home) puts the search field in the sticky topstrip instead: with Dashboard and Discover competing for the top of the page, a full-height hero pushed both of them below the fold and the page opened on nothing but its own title. #} {% if not new_home or all_in_one %}

{{ _('What do you want to download?') }}

{{ _('One title is enough — MediaForge asks every active source at once.') }}

{# The Seerr modal reuses .search-bar, so the home-only pill styling hangs off the extra .home-searchbar class instead of touching .search-bar itself. "All in one page" has no topstrip to carry #topstripCustomize, so its copy of the search bar gets the settings button inline instead. #} {{ home_searchbar(show_customize=all_in_one) }} {% endif %} {# The panel-bar keys below (panel_*, hp_*) live in this template rather than in routes/home_panels.py because babel.cfg extracts Jinja templates ONLY -- a _() in Python source never reaches a catalogue. The server sends the key, static/home_panels.js looks it up here. Placeholders are written as "{}" and not "%s": Flask-Babel installs newstyle gettext, which rewrites %s to {} in the rendered string while the catalogue keeps %s -- "{}" keeps template, catalogue and JS in step. NOTE: do not put a Jinja comment inside the dict below -- a comment is not allowed inside an expression and takes the whole page down with a TemplateSyntaxError. (Nor inside this one: the closing delimiter of a nested comment ends the outer one and the rest leaks onto the page.) #} {# Every string the home page renders client-side, translated here so it goes through the same Flask-Babel catalogue as the rest of the app instead of a hardcoded de/en pair in JavaScript. Rendered for both home variants -- the search box below is shared. The `status_*` keys are the filter dropdown that answers "have I already got this?", as opposed to the Type menu's "what is it?". Both of its entries are on by default, and both are worded exactly like the badges they remove ("Vorhanden" and the Sync pill) -- a different word for the same thing is a filter people have to try out to understand. NEVER put a bare percent sign in one of these msgids. Newstyle gettext runs the rendered string through %-formatting, so "% m" is read as a format spec and raises ValueError -- which 500s the entire home page. `fy_match` therefore reads "{} match" and home_foryou.js hands it a value that already carries its own "%". #} {# The panel bar: buttons under the search field, one panel below them. Both are empty until /api/home-panels answers -- which panels an account may even see is decided server-side (Storage and System are admin-only), so rendering the buttons here would mean rendering buttons that 403. The bar hides itself when nothing is available. #} {# The panel bar moved INSIDE the Dashboard pane (further down) — it is the dashboard, so leaving it above the tab strip would have shown instance stats while the Discover tab was open. #} {# Which sources a search actually hits. Filled by renderSourceChips() in app.js once /api/settings answered; empty (and invisible) until then. The new home page does NOT render this: its own chip row (below) says the same thing *and* filters, and two rows of identical source names stacked on top of each other is a puzzle, not a control. #} {% if not new_home %}
{# The way in to "which home page do I want", on the classic layout too. Not optional politeness: /settings redirects a non-admin to "/", so this modal is the ONLY place a normal account can pick a layout -- and the promo banner above is gone for good once it has been answered. Without this, dismissing the banner would be a one-way door onto v1. #}
{% endif %} {# Only rendered while something is downloading -- see renderHomeRunStrip(), fed by the queue poll that runs anyway. #}
{{ _('Searching...') }}
{# The way back out of a search. Its OWN element, above #results and not inside it: doSearch() replaces #results.innerHTML wholesale when the answers arrive, so a header rendered in there survived exactly until the first result landed. #}
{# Dashboard widgets from enabled extensions (see register_thirdparty(dashboard_widget_template=...) in web/thirdparties/registry.py) — each entry's own template is included as-is, so the widget's markup/behaviour is entirely up to the extension; this just provides the slot + consistent spacing (.browse-provider-block, reused for its margin only — no provider dot styling implied). Nothing renders here if no extension registered one. Classic layout only -- it has no Dashboard sections at all, so this plain block-per-widget rendering is all it ever needs. On the two-tab layout the same registry entries instead render inside the "Modules" section, wrapped in .dash-card markup, so they are real draggable/ hideable cards (see the loop in that section below, and home_panels.js's "thirdparty-template" handling). Rendered here, outside both panes, a module widget used to show up on Discover as well -- fixed by moving that loop, not this one. #} {% if not new_home %} {% for widget in dashboard_widgets %}
{# "ignore missing" -- a widget template that was removed/renamed after being registered (e.g. mid-upgrade) silently disappears instead of taking down the entire home page for every user. #} {% include widget.template ignore missing %}
{% endfor %} {% endif %} {# Settings -> General -> "Use the new home page": one row per question, every enabled source mixed into it, instead of one block per source. The classic block below stays the default and is what modules extend. #} {% if new_home %}
{# ── Tab 1: Dashboard ──────────────────────────────────────────────────── What is happening on THIS instance: the panel bar, anything that needs attention, and the rows that are about the user's own material. Nothing in here asks a source a question, so it renders without waiting on the network. Skipped entirely (not just hidden) when the account switched the Dashboard tab off -- "turning it off" should also mean MediaForge stops building and fetching all of this every load, not just stop showing it. #} {% if dash_enabled %}
{# What this instance still needs, above the cards: it is the one thing on this page that asks the user to go and do something, and underneath six cards it was furniture. Filled by static/home_2_1.js and absent entirely on a fully set-up instance. #} {% if all_in_one %} {# "All in one page": the pre-v3-grid Dashboard design instead of the card grid -- a button row plus ONE panel below (queue/activity/ library/storage/system), built entirely client-side by the revived static/home_panel_bar.js against the same /api/home-panels(-/) endpoints the grid's /api/home-panels/all also reads. See that file's own docstring for why it was retired and why it is back for exactly this mode. Both start empty/hidden: which panels an account may even see is a server answer (Storage/System are admin-only). #}
{% else %} {# The Dashboard is 2 or 3 columns (dash_columns, per account). A card picks a COLUMN and a position inside it, and that is the whole arrangement model -- there are no named groups any more, so nothing here decides what a card "is about", only where it sits. Which card starts in which column is a static id->column map in static/home_panels.js (COLUMN_OF); a module panel or dashboard widget with no entry there starts in the middle column. A saved home_dash_card_layout overrules both, for position as well as column. The columns are rendered server-side, empty, because they are the drop targets home_panels.js places cards into -- and because an account with three columns must not see them appear one paint late. On a phone the .dash-col wrappers dissolve (display: contents, see index.css) so the cards stack in DOM order: column 1 top to bottom, then column 2, then column 3. One arrangement, two viewports. #}
{% for col in range(dash_columns) %}
{# Module dashboard widgets start in the middle column, the same default COLUMN_OF gives a module PANEL. Server-rendered here (not built by JS) because their markup is the module's own Jinja. #} {% if col == 1 %} {% for widget in dashboard_widgets %} {{ dashboard_widget_card(widget) }} {% endfor %} {% endif %}
{% endfor %}
{% endif %} {# Personal rows: what you were doing beats what a site published. Each one hides itself when its source has nothing to say (no playback position yet, no favourites, calendar switched off), so a fresh install shows an empty Dashboard and everything worth looking at under Discover. #} {# The only row that asks something of you rather than offering something, which is why its default position is last among the personal rows (see _FEED_DEFAULT_ORDER in routes/browse.py). #} {# "Head over to Discover" makes sense as a nudge to switch tabs -- in "All in one page" there is no other tab to head over to, Discover is already the very next thing on the page, so the message would just be a slightly wrong sentence sitting above it. mfHomeSyncDashEmpty() (home_2_1.js) already no-ops without #homeDashEmpty. #} {% if not all_in_one %} {% endif %} {# Module dashboard widgets render inside the "Modules" section above, as real .dash-card elements -- see the loop there and resolve_dashboard_widgets() in web/thirdparties/registry.py. A second copy used to render here too; removed, it would have produced duplicate DOM ids for every registered widget. #}
{% endif %} {# ── Tab 2: Discover ───────────────────────────────────────────────────── Everything that comes from outside: the recommendation hero, then the source rows. This is also the only pane the source/type/status filters apply to, which is why the toolbar lives in here and not above the tabs. Starts visible (not hidden) whenever there is no tab pill to open it later -- no Dashboard tab at all, or "All in one page" stacking both with nothing to switch. home_2_1.js's showTab() still owns which pane is open when there IS a pill; this is only the no-JS/first-paint state, and wireTabs() already bails out before touching either pane when the bar itself does not exist (see its own comment). #}
{# ── "Could be for you" ────────────────────────────────────────────── The only row on this page built from titles the user does NOT have. Candidates come from the TMDB recommendations already sitting in the cache for the titles they DO have, so the row costs no extra traffic; only the five hero entries are looked up in full (backdrop + plot), and those are cached for a day. Both children start hidden: which one applies is a server answer (/api/home-feed/foryou -> configured), and guessing here would flash a setup prompt at every user who already set CineInfo up. #} {# The chip row and the way in to "which rows do I even want". /settings is admin-only, so this is where a normal account reaches its own Start Page settings -- the same controls, same code, same storage. #}
{# Density and modes are rendered by static/home_2_1.js: both depend on a stored preference, and a server-rendered default would flash the wrong one on every load. #}
{# "Because you watched X". The heading carries a {} placeholder that home_feed.js fills with the seed title -- the row is the only guess on this page, and naming what it guessed FROM is what makes a wrong card read as "wrong guess" instead of "why is this here". #}

{{ _('New this week') }}

{{ _('Popular right now') }}

{{ _('Movies') }}

{% else %}
AniWorld

{{ _('New Anime') }}

{{ _('Popular Anime') }}

SerienStream

{{ _('New Series') }}

{{ _('Popular Series') }}

FilmPalast

{{ _('New Movies') }}

MegaKino

{{ _('New Movies') }}

{{ _('Popular Movies') }}

{{ _('New Series') }}

{{ _('Popular Series') }}

filmo.to

{{ _('New Movies') }}

{{ _('Popular Movies') }}

{% endif %}
{# Start Page settings for this account. Same macro the Settings tab uses -- an admin sees the instance defaults there as well, everyone sees their own here. Rendered on BOTH layouts: /settings redirects a non-admin, so this is the only place a normal account can switch between them at all. #} {# Kids-mode PIN. Its own overlay rather than window.prompt(): a system prompt cannot mask the digits, is unstyled, blocks the tab, and on a phone appears as a sheet that does not look like it belongs to MediaForge. #} {% if new_home %} {% endif %} {% include "shared_modals.html" %} {# Same "Could be for you" Details button as above -- markup only needed in the new_home layout. #} {% if new_home %}{% include "mf_detail_modal.html" %}{% endif %} {% endblock %} {% block scripts %} {% if new_home %} {# "All in one page" only -- self-gated on #homePanelBar/#homePanelBody, so harmless to load whenever it finds neither (tabs/Discover-only modes). #} {# Settings -> Start Page's instance default for which tab opens first (window._USER_PREFS.home_tab overrules it, same as everywhere else on this page) -- home_2_1.js's wireTabs() reads this only when the account never set a tab of its own. #} {% endif %} {{ startpage.start_page_i18n() }} {% if devinfo_importants %}{% endif %} {% if show_new_home_promo %} {% endif %} {% endblock %}