ForumSpark Support

support.forumspark.net

Official support and community forum for ForumSpark. Access announcements, troubleshooting, customization help, extension development, theme resources, and platform updates.

198 members 650 topics 3K replies
J jefx ·Customization Help ·1 month ago
I need help with the vista theme

i love that theme, but i want the notification buttons and the other i can’t remember at the top with the looking of vista theme.

i wonder if i have to remake the whole code again or just a part of it.

3 replies 19 Read more →
Cedric ·Customization Help ·1 month ago
Theme Customizer

Some of you may have seen the “My view” and “Board setup” theme customizer that is often shipped with some of my themes. This is a lightweight solution giving some options to themes. The idea is to bring something more advanced inside the ACP and dev boards will be able to register and wire certain settings to their theme. This is coming somewhere in the near future. In this topic, I’ll explain how it works.

Everything below runs inside your theme's layout HTML and CSS.

The ideas is to have one CSS variable, with three levels of override:

Your theme CSS should never use a raw color twice. Define a small set of theme variables, and give each one a fallback chain:

body.myt {
    --myt-accent: var(--mytp-accent, #3ec9a7);
}
body.myt .btn-primary {
    background: var(--myt-accent);
}

Read it right to left:

1. #3ec9a7 is your theme default, shipped in the CSS.

2. --mytp-accent is the admin override. It only exists when the admin saved one (see Board setup below), otherwise the fallback wins.

3. A member override is applied on top by setting --myt-accent inline on body from JavaScript, which beats the rule above.

Convention: use one prefix for the variables your CSS consumes myt-) and a second one for the admin-saved properties mytp-). That separation is what makes the three levels compose.

Board setup: theme custom properties

Admin choices are stored on the theme row as custom properties, a simple name to value map. The platform renders them into your theme stylesheet automatically:

:root {
    --mytp-accent: #7a5cf0;
    --mytp-heads: crown;
}

So a saved property is just a CSS variable that exists on every page load, before anything paints. No JavaScript involved in applying it.

Rules the platform enforces:

- Names: lowercase letters, digits and dashes, up to 40 characters, stored without the leading -- (you save mytp-accent, it renders as --mytp-accent).

- Values: a hex color, var(--something), a url("/storage/..."), a theme resource reference, or plain text up to 80 characters (letters, digits, spaces, . , % -).

- At most 40 properties per theme.

Reading and saving from the panel

There is one JSON endpoint for it, on the board's admin surface:

GET  /admin/themes/{id}/appearance

PUT  /admin/themes/{id}/appearance

GET returns the current state:

{
    "theme": { "id": 66, "name": "Nimbus" },
    "defaultColorScheme": "dark",
    "memberBackdrops": false,
    "customProperties": { "mytp-accent": "#7a5cf0", "mytp-heads": "crown" },
    "resources": [ { "name": "hero.jpg", "url": "..." } ]
}

PUT accepts partial updates. Send only the keys you are changing; unsent keys are untouched. null (or an empty string) deletes a property, which returns it to your theme default through the fallback chain:

fetch('/admin/themes/' + themeId + '/appearance', {
    method: 'PUT',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        'Accept': 'application/json'
    },
    credentials: 'same-origin',
    body: JSON.stringify({
        properties: { 'mytp-accent': '#7a5cf0', 'mytp-heads': null },
        defaultColorScheme: 'dark'
    })
});

Saving bumps the theme's version hash, so every visitor's cached stylesheet refreshes on their next page load. You never need to bust caches yourself.

Two practical details from for example Nimbus:

- Finding your theme id in JS: read it from your own stylesheet link, document.querySelector('link[href*="/main.css"]') and match themes/(\d+)/main.css.

- Showing the tab only to admins: just try the GET. It succeeds for board admins and fails for everyone else, so reveal the Board setup tab when the response is OK and keep it hidden otherwise. No separate permission check needed.

After saving, apply it live

A save only affects the next page load, so mirror the change immediately for the admin who clicked:

document.documentElement.style.setProperty('--mytp-accent', '#7a5cf0');
document.documentElement.style.removeProperty('--mytp-heads');

My view: member choices in localStorage

Member preferences never touch the server. Store them in localStorage and apply them from a small script at the very top of your layout, before any content markup. That placement matters: it runs before the page paints, so the saved look is there on the first frame and nothing flashes.

<script>(function () {
    var b = document.body;
    var accent = null;
    try { accent = localStorage.getItem('myt-accent'); } catch (e) {}
    if (accent) { b.style.setProperty('--myt-accent', accent); }
    var motion = 'alive';
    try { motion = localStorage.getItem('myt-motion') || 'alive'; } catch (e) {}
    if (motion === 'calm' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        b.classList.add('myt-calm');
    }
})();</script>

Pair class toggles like myt-calm with CSS that stills your animations:

body.myt.myt-calm .myt-hero { animation: none; }

Recipes

An admin feature toggle (absent means on)

Store off when disabled and delete the property when enabled. Your boot script reads it straight from CSS, so the saved state needs no extra round trip:

var spineOff = getComputedStyle(document.body).getPropertyValue('--mytp-spine').trim() === 'off';
if (! spineOff) { document.body.classList.add('myt-spine-on'); }

Panel wiring: toggle on sends { properties: { 'mytp-spine': null } }, toggle off sends { properties: { 'mytp-spine': 'off' } }.

A permission the admin grants to members

Nimbus has a "Members may restyle" switch. It is just another property nbp-members-restyle: 0 when revoked. The member boot script checks it before applying any saved member accent, and the panel hides the member section when it is 0. Members keep their stored choice; it simply stops applying until the admin allows it again.

A board default pair with a member override

The admin saves mytp-a1 and mytp-a2 (Board setup). A member picking their own pair writes to localStorage and sets --myt-a1 / --myt-a2 inline on body (My view). Clearing the member choice removes the inline properties and the admin defaults show through again. This is exactly the chain from the first section, in practice.

Checklist

  • Theme CSS consumes --myt-* variables with var(--mytp-*, default) fallbacks. No repeated raw values.

  • Boot script at the top of the layout applies member state before first paint.

  • Board setup tab appears only when GET .../appearance succeeds.

  • Saves send only changed keys, use null to delete, and apply the change locally as well.

  • A Calm or reduced-motion path exists for every animation.

  • Property names fit [a-z][a-z0-9-]* and values fit the allowed formats (hex, var, url, resource, short plain text).

If there’s any questions, happy to answer them.

1 reply 11 Read more →
Cedric ·Customization Help ·1 month ago
Theme suggestions

If you have some theme suggestions for our directory, happy to hear them. Could be screenshots, links to existing themes, a concept, whatever. 🙂 Feel free to drop them here and who knows…

3 replies 21 Read more →
Daedalus ·Customization Help ·5 months ago
HTML User Rank Button

I was playing around with the help of Gemini and created a user rank button that has a customizable design and will display the current 'rank'.

I can share the coding if others are interested.

25 replies 288 Read more →
Cedric ·Customization Help ·3 months ago
Custom Templates: Topic list template

Here are some examples for the topic list view:

Template:

<div class="pl">
{{#if pinned}}
{{#each pinned}}
<div class="pl-card pl-card--pinned{{#unless this.is_read}} pl-card--unread{{/unless}}">
    <span class="pl-avatar">
        <a href="{{ this.author.profile_url }}"><img src="{{ this.author.avatar_url }}" alt="{{ this.author.username }}" /></a>
    </span>
    <div class="pl-main">
        <div class="pl-chips">
            <span class="pl-pin"><span class="fa-solid fa-thumbtack"></span> Pinned</span>
            {{#each this.labels}}
                <span class="pl-chip">{{ this.title }}</span>
            {{/each}}
            {{#if this.is_locked}}<span class="pl-state"><span class="fa-solid fa-lock"></span> Locked</span>{{/if}}
            {{#if this.is_solved}}<span class="pl-state pl-state--solved"><span class="fa-solid fa-check"></span> Solved</span>{{/if}}
        </div>
        <div class="pl-title"><a href="{{ this.url }}">{{ this.title }}</a></div>
        <div class="pl-meta">{{ this.author.username }} <span class="pl-sep">·</span> {{ this.created_at }}</div>
        <div class="pl-foot">
            <span class="pl-stat"><span class="fa-regular fa-comment"></span> {{ this.replies_count }}</span>
            <span class="pl-stat"><span class="fa-regular fa-eye"></span> {{ this.views_short }}</span>
            {{#if this.last_post.author}}
            <span class="pl-activity">
                <img src="{{ this.last_post.avatar_url }}" alt="{{ this.last_post.author }}" />
                <a href="{{ this.last_post.url }}">{{ this.last_post.author }} <span class="pl-sep">·</span> {{ this.last_post.date }}</a>
            </span>
            {{/if}}
        </div>
    </div>
    {{{ this.checkbox }}}
</div>
{{/each}}
{{/if}}
{{#each topics}}
<div class="pl-card{{#unless this.is_read}} pl-card--unread{{/unless}}">
    <span class="pl-avatar">
        <a href="{{ this.author.profile_url }}"><img src="{{ this.author.avatar_url }}" alt="{{ this.author.username }}" /></a>
    </span>
    <div class="pl-main">
        <div class="pl-chips">
            {{#each this.labels}}
                <span class="pl-chip">{{ this.title }}</span>
            {{/each}}
            {{#if this.is_hot}}<span class="pl-state pl-state--hot"><span class="fa-solid fa-fire-flame-curved"></span> Hot</span>{{/if}}
            {{#if this.is_locked}}<span class="pl-state"><span class="fa-solid fa-lock"></span> Locked</span>{{/if}}
            {{#if this.is_solved}}<span class="pl-state pl-state--solved"><span class="fa-solid fa-check"></span> Solved</span>{{/if}}
        </div>
        <div class="pl-title"><a href="{{ this.url }}">{{ this.title }}</a></div>
        <div class="pl-meta">{{ this.author.username }} <span class="pl-sep">·</span> {{ this.created_at }}</div>
        <div class="pl-foot">
            <span class="pl-stat"><span class="fa-regular fa-comment"></span> {{ this.replies_count }}</span>
            <span class="pl-stat"><span class="fa-regular fa-eye"></span> {{ this.views_short }}</span>
            {{#if this.participants}}
            <span class="pl-people">
                {{#each this.participants}}
                    <img src="{{ this.avatar_url }}" alt="{{ this.username }}" />
                {{/each}}
            </span>
            {{/if}}
            {{#if this.last_post.author}}
            <span class="pl-activity">
                <img src="{{ this.last_post.avatar_url }}" alt="{{ this.last_post.author }}" />
                <a href="{{ this.last_post.url }}">{{ this.last_post.author }} <span class="pl-sep">·</span> {{ this.last_post.date }}</a>
            </span>
            {{/if}}
        </div>
    </div>
    {{{ this.checkbox }}}
</div>
{{/each}}
{{#unless topics}}{{#unless pinned}}
<div class="pl-empty">No topics here yet.</div>
{{/unless}}{{/unless}}
{{#if pagination.has_pages}}
<div class="pl-pages">
    Page {{ pagination.current_page }} of {{ pagination.last_page }}
    {{#if pagination.prev_url}}<a href="{{ pagination.prev_url }}">Previous</a>{{/if}}
    {{#if pagination.next_url}}<a href="{{ pagination.next_url }}">Next</a>{{/if}}
</div>
{{/if}}
</div>

CSS:

.pl { display: flex; flex-direction: column; gap: 0.65rem; padding: 0.75rem; }
.pl-card { display: flex; gap: 0.8rem; padding: 0.9rem 1rem; background: var(--fs-topic-bg, rgba(128,128,128,0.04)); border: 1px solid var(--fs-panel-border, rgba(128,128,128,0.18)); border-radius: 0.75rem; transition: border-color 0.14s ease, box-shadow 0.14s ease; }
.pl-card:hover { border-color: var(--fs-panel-footer-accent, rgba(128,128,128,0.4)); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06); }
.pl-card--unread { box-shadow: inset 3px 0 0 var(--fs-panel-footer-accent, currentColor); }
.pl-card--pinned { background: var(--fs-panel-section-bg, rgba(128,128,128,0.07)); }
.pl-avatar { flex-shrink: 0; }
.pl-avatar img { width: 2.6rem; height: 2.6rem; border-radius: 50%; object-fit: cover; display: block; }
.pl-main { flex: 1; min-width: 0; }
.pl-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; margin-bottom: 0.25rem; }
.pl-pin, .pl-chip, .pl-state { display: inline-flex; align-items: center; gap: 0.28rem; font-size: 0.62rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; padding: 0.14rem 0.5rem; border-radius: 999px; }
.pl-pin { color: #fff; background: var(--fs-panel-footer-accent, #35a377); }
.pl-chip { background: var(--fs-panel-section-bg, rgba(128,128,128,0.12)); opacity: 0.85; }
.pl-state { border: 1px solid var(--fs-panel-border, rgba(128,128,128,0.25)); opacity: 0.8; }
.pl-state--solved { color: var(--fs-panel-footer-accent, inherit); border-color: var(--fs-panel-footer-accent, rgba(128,128,128,0.4)); opacity: 1; }
.pl-state--hot { opacity: 1; }
.pl-title a { font-size: 1rem; font-weight: 700; letter-spacing: -0.01em; color: var(--fs-forum-link-color, inherit); text-decoration: none; }
.pl-title a:hover { color: var(--fs-panel-footer-accent, inherit); }
.pl-card--unread .pl-title a { color: var(--fs-panel-footer-accent, inherit); }
.pl-meta { font-size: 0.73rem; opacity: 0.6; margin-top: 0.1rem; }
.pl-sep { opacity: 0.5; margin: 0 0.1rem; }
.pl-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin-top: 0.55rem; font-size: 0.74rem; }
.pl-stat { display: inline-flex; align-items: center; gap: 0.32rem; opacity: 0.75; font-variant-numeric: tabular-nums; }
.pl-people { display: inline-flex; }
.pl-people img { width: 1.4rem; height: 1.4rem; border-radius: 50%; object-fit: cover; border: 2px solid var(--fs-topic-bg, #fff); margin-left: -0.4rem; }
.pl-people img:first-child { margin-left: 0; }
.pl-activity { display: inline-flex; align-items: center; gap: 0.4rem; margin-left: auto; min-width: 0; }
.pl-activity img { width: 1.35rem; height: 1.35rem; border-radius: 50%; object-fit: cover; }
.pl-activity a { font-size: 0.72rem; color: inherit; opacity: 0.7; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pl-activity a:hover { opacity: 1; }
.pl-card .topic-row__bulk-select { align-self: center; }
.pl-empty { padding: 1.6rem 1rem; text-align: center; opacity: 0.7; }
.pl-pages { padding: 0.4rem 0.3rem; font-size: 0.78rem; opacity: 0.8; display: flex; gap: 0.8rem; }
.pl-pages a { color: var(--fs-link-color, inherit); font-weight: 600; text-decoration: none; }
.pl-pages a:hover { text-decoration: underline; }
@media (max-width: 640px) {
    .pl-activity { display: none; }
    .pl-people { display: none; }
}

Template:

<div class="tk">
<div class="tk-cols">
    <span></span>
    <span>Topic</span>
    <span class="tk-r">Replies</span>
    <span class="tk-r">Views</span>
    <span>Activity</span>
</div>
{{#if pinned}}
{{#each pinned}}
<div class="tk-row tk-row--pinned{{#unless this.is_read}} tk-row--unread{{/unless}}">
    <span class="tk-glyph"><span class="fa-solid fa-thumbtack"></span></span>
    <div class="tk-main">
        <div class="tk-title">
            <a href="{{ this.url }}">{{ this.title }}</a>
            {{#each this.labels}}
                <span class="tk-label">{{ this.title }}</span>
            {{/each}}
            {{#if this.is_locked}}<span class="tk-mini fa-solid fa-lock"></span>{{/if}}
            {{#if this.is_solved}}<span class="tk-mini tk-mini--solved fa-solid fa-check"></span>{{/if}}
        </div>
        <div class="tk-meta">{{ this.author.username }} · {{ this.created_at }}</div>
    </div>
    <span class="tk-num">{{ this.replies_count }}</span>
    <span class="tk-num">{{ this.views_short }}</span>
    <div class="tk-act">
        {{#if this.last_post.author}}
        <a href="{{ this.last_post.url }}">{{ this.last_post.author }}</a>
        <span>{{ this.last_post.date }}</span>
        {{/if}}
    </div>
    {{{ this.checkbox }}}
</div>
{{/each}}
{{/if}}
{{#each topics}}
<div class="tk-row{{#unless this.is_read}} tk-row--unread{{/unless}}">
    <span class="tk-glyph">
        {{#if this.is_locked}}
            <span class="fa-solid fa-lock"></span>
        {{else}}
            {{#if this.is_hot}}
                <span class="tk-hot fa-solid fa-fire-flame-curved"></span>
            {{else}}
                {{#if this.is_solved}}
                    <span class="tk-ok fa-solid fa-check"></span>
                {{else}}
                    <span class="tk-dot"></span>
                {{/if}}
            {{/if}}
        {{/if}}
    </span>
    <div class="tk-main">
        <div class="tk-title">
            <a href="{{ this.url }}">{{ this.title }}</a>
            {{#each this.labels}}
                <span class="tk-label">{{ this.title }}</span>
            {{/each}}
        </div>
        <div class="tk-meta">{{ this.author.username }} · {{ this.created_at }}</div>
    </div>
    <span class="tk-num">{{ this.replies_count }}</span>
    <span class="tk-num">{{ this.views_short }}</span>
    <div class="tk-act">
        {{#if this.last_post.author}}
        <a href="{{ this.last_post.url }}">{{ this.last_post.author }}</a>
        <span>{{ this.last_post.date }}</span>
        {{/if}}
    </div>
    {{{ this.checkbox }}}
</div>
{{/each}}
{{#unless topics}}{{#unless pinned}}
<div class="tk-empty">No topics here yet.</div>
{{/unless}}{{/unless}}
{{#if pagination.has_pages}}
<div class="tk-pages">
    Page {{ pagination.current_page }} of {{ pagination.last_page }}
    {{#if pagination.prev_url}}<a href="{{ pagination.prev_url }}">Previous</a>{{/if}}
    {{#if pagination.next_url}}<a href="{{ pagination.next_url }}">Next</a>{{/if}}
</div>
{{/if}}
</div>

CSS:

.tk-cols, .tk-row { display: grid; grid-template-columns: 1.7rem minmax(0, 1fr) 4.5rem 4.5rem 11rem; gap: 0.9rem; align-items: center; padding: 0 0.9rem; }
.tk:has(.topic-row__bulk-select) .tk-cols,
.tk:has(.topic-row__bulk-select) .tk-row { grid-template-columns: 1.7rem minmax(0, 1fr) 4.5rem 4.5rem 11rem 1.6rem; }
.topic-row__bulk-select { display: flex; justify-content: center; }
.tk-cols { padding-top: 0.55rem; padding-bottom: 0.45rem; font-size: 0.58rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.11em; opacity: 0.45; border-bottom: 1px solid var(--fs-panel-border, rgba(128,128,128,0.2)); }
.tk-r { text-align: right; }
.tk-row { padding-top: 0.55rem; padding-bottom: 0.55rem; }
.tk-row + .tk-row { border-top: 1px solid var(--fs-forum-divider, rgba(128,128,128,0.1)); }
.tk-row:hover { background: var(--fs-panel-section-bg, rgba(128,128,128,0.05)); }
.tk-row--pinned { background: var(--fs-panel-section-bg, rgba(128,128,128,0.06)); }
.tk-glyph { display: flex; align-items: center; justify-content: center; font-size: 0.72rem; opacity: 0.65; }
.tk-row--pinned .tk-glyph { color: var(--fs-panel-footer-accent, inherit); opacity: 1; }
.tk-hot { color: var(--fs-panel-footer-accent, inherit); }
.tk-ok { color: var(--fs-panel-footer-accent, inherit); }
.tk-dot { width: 0.42rem; height: 0.42rem; border-radius: 50%; background: currentColor; opacity: 0.28; }
.tk-row--unread .tk-dot { background: var(--fs-panel-footer-accent, currentColor); opacity: 1; }
.tk-main { min-width: 0; }
.tk-title { display: flex; align-items: center; gap: 0.45rem; min-width: 0; flex-wrap: wrap; }
.tk-title a { font-size: 0.86rem; font-weight: 500; color: var(--fs-forum-link-color, inherit); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tk-title a:hover { text-decoration: underline; }
.tk-row--unread .tk-title a { font-weight: 750; }
.tk-label { font-size: 0.58rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.1rem 0.42rem; border-radius: 0.28rem; background: var(--fs-panel-section-bg, rgba(128,128,128,0.12)); opacity: 0.85; }
.tk-mini { font-size: 0.62rem; opacity: 0.6; }
.tk-mini--solved { color: var(--fs-panel-footer-accent, inherit); opacity: 1; }
.tk-meta { font-size: 0.67rem; opacity: 0.52; margin-top: 0.05rem; }
.tk-num { text-align: right; font-size: 0.8rem; font-weight: 600; font-variant-numeric: tabular-nums; opacity: 0.8; }
.tk-act { display: flex; flex-direction: column; min-width: 0; line-height: 1.35; }
.tk-act a { font-size: 0.72rem; font-weight: 600; color: inherit; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tk-act a:hover { text-decoration: underline; }
.tk-act span { font-size: 0.64rem; opacity: 0.5; }
.tk-empty { padding: 1.6rem 1rem; text-align: center; opacity: 0.7; }
.tk-pages { padding: 0.6rem 0.9rem; font-size: 0.76rem; opacity: 0.8; display: flex; gap: 0.8rem; border-top: 1px solid var(--fs-panel-border, rgba(128,128,128,0.2)); }
.tk-pages a { color: var(--fs-link-color, inherit); font-weight: 600; text-decoration: none; }
.tk-pages a:hover { text-decoration: underline; }
@media (max-width: 760px) {
    .tk-cols { display: none; }
    .tk-row { grid-template-columns: 1.4rem minmax(0, 1fr) 3rem; gap: 0.6rem; }
    .tk:has(.topic-row__bulk-select) .tk-row { grid-template-columns: 1.4rem minmax(0, 1fr) 3rem 1.6rem; }
    .tk-num + .tk-num { display: none; }
    .tk-act { display: none; }
}

Template:

<div class="hd">
{{#if pinned}}
<div class="hd-eyebrow">Featured</div>
<div class="hd-features">
    {{#each pinned}}
    <div class="hd-hero{{#unless this.is_read}} hd-hero--unread{{/unless}}">
        <div class="hd-hero-chips">
            <span class="hd-pin"><span class="fa-solid fa-thumbtack"></span></span>
            {{#each this.labels}}
                <span class="hd-chip">{{ this.title }}</span>
            {{/each}}
            {{#if this.is_locked}}<span class="hd-chip"><span class="fa-solid fa-lock"></span> Locked</span>{{/if}}
        </div>
        <div class="hd-hero-title"><a href="{{ this.url }}">{{ this.title }}</a></div>
        <div class="hd-hero-meta">
            <img src="{{ this.author.avatar_url }}" alt="{{ this.author.username }}" />
            <span>{{ this.author.username }} <span class="hd-sep">·</span> {{ this.created_at }}</span>
        </div>
        <div class="hd-hero-stats">
            <b>{{ this.replies_count }}</b> replies <span class="hd-sep">·</span> <b>{{ this.views_short }}</b> views
            {{#if this.last_post.author}}
            <span class="hd-sep">·</span> last {{ this.last_post.author }}, {{ this.last_post.date }}
            {{/if}}
        </div>
    </div>
    {{/each}}
</div>
{{/if}}
<div class="hd-eyebrow">Latest</div>
<div class="hd-list">
    {{#each topics}}
    <div class="hd-row{{#unless this.is_read}} hd-row--unread{{/unless}}">
        <div class="hd-main">
            <div class="hd-title">
                <a href="{{ this.url }}">{{ this.title }}</a>
                {{#if this.is_hot}}<span class="hd-flame fa-solid fa-fire-flame-curved"></span>{{/if}}
                {{#if this.is_solved}}<span class="hd-check fa-solid fa-check"></span>{{/if}}
                {{#if this.is_locked}}<span class="hd-lock fa-solid fa-lock"></span>{{/if}}
            </div>
            <div class="hd-meta">
                {{#each this.labels}}
                    <span class="hd-tag">{{ this.title }}</span>
                {{/each}}
                {{ this.author.username }} <span class="hd-sep">·</span> {{ this.created_at }}
            </div>
        </div>
        <div class="hd-side">
            <span class="hd-count"><b>{{ this.replies_count }}</b> replies</span>
            <span class="hd-count hd-count--dim"><b>{{ this.views_short }}</b> views</span>
            {{#if this.last_post.author}}
            <a href="{{ this.last_post.url }}" class="hd-when">{{ this.last_post.date }}</a>
            {{/if}}
        </div>
        {{{ this.checkbox }}}
    </div>
    {{/each}}
</div>
{{#unless topics}}{{#unless pinned}}
<div class="hd-empty">No topics here yet.</div>
{{/unless}}{{/unless}}
{{#if pagination.has_pages}}
<div class="hd-pages">
    Page {{ pagination.current_page }} of {{ pagination.last_page }}
    {{#if pagination.prev_url}}<a href="{{ pagination.prev_url }}">Previous</a>{{/if}}
    {{#if pagination.next_url}}<a href="{{ pagination.next_url }}">Next</a>{{/if}}
</div>
{{/if}}
</div>

CSS:

.hd { padding: 0.9rem; }
.hd-eyebrow { font-size: 0.62rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.18em; opacity: 0.5; margin: 0 0 0.6rem; }
.hd-list + .hd-eyebrow, .hd-features + .hd-eyebrow { margin-top: 1.4rem; }
.hd-features { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.8rem; margin-bottom: 1.4rem; }
.hd-hero { padding: 1rem 1.1rem; background: var(--fs-panel-section-bg, rgba(128,128,128,0.06)); border: 1px solid var(--fs-panel-border, rgba(128,128,128,0.18)); border-top: 3px solid var(--fs-panel-footer-accent, rgba(128,128,128,0.4)); border-radius: 0.6rem; }
.hd-hero--unread { background: var(--fs-topic-bg, rgba(128,128,128,0.08)); }
.hd-hero-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; margin-bottom: 0.4rem; }
.hd-pin { display: inline-flex; align-items: center; justify-content: center; width: 1.4rem; height: 1.4rem; border-radius: 50%; color: #fff; background: var(--fs-panel-footer-accent, #35a377); font-size: 0.6rem; }
.hd-chip { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; padding: 0.13rem 0.5rem; border-radius: 999px; background: var(--fs-panel-bg, rgba(255,255,255,0.6)); border: 1px solid var(--fs-panel-border, rgba(128,128,128,0.2)); opacity: 0.85; }
.hd-hero-title a { font-size: 1.22rem; font-weight: 800; letter-spacing: -0.02em; line-height: 1.25; color: var(--fs-forum-link-color, inherit); text-decoration: none; }
.hd-hero-title a:hover { color: var(--fs-panel-footer-accent, inherit); }
.hd-hero--unread .hd-hero-title a { color: var(--fs-panel-footer-accent, inherit); }
.hd-hero-meta { display: flex; align-items: center; gap: 0.45rem; margin-top: 0.5rem; font-size: 0.73rem; opacity: 0.7; }
.hd-hero-meta img { width: 1.5rem; height: 1.5rem; border-radius: 50%; object-fit: cover; }
.hd-hero-stats { margin-top: 0.55rem; font-size: 0.73rem; opacity: 0.75; font-variant-numeric: tabular-nums; }
.hd-hero-stats b { font-weight: 800; }
.hd-sep { opacity: 0.45; margin: 0 0.12rem; }
.hd-row { display: flex; align-items: center; gap: 1.2rem; padding: 0.75rem 0.2rem; }
.hd-row + .hd-row { border-top: 1px solid var(--fs-forum-divider, rgba(128,128,128,0.12)); }
.hd-row:hover { background: var(--fs-panel-section-bg, rgba(128,128,128,0.04)); }
.hd-main { flex: 1; min-width: 0; }
.hd-title { display: flex; align-items: center; gap: 0.45rem; min-width: 0; }
.hd-title a { font-size: 0.97rem; font-weight: 650; letter-spacing: -0.01em; color: var(--fs-forum-link-color, inherit); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hd-title a:hover { color: var(--fs-panel-footer-accent, inherit); }
.hd-row--unread .hd-title a { font-weight: 850; color: var(--fs-panel-footer-accent, inherit); }
.hd-flame, .hd-check { font-size: 0.7rem; color: var(--fs-panel-footer-accent, inherit); }
.hd-lock { font-size: 0.68rem; opacity: 0.55; }
.hd-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; font-size: 0.7rem; opacity: 0.6; margin-top: 0.12rem; }
.hd-tag { font-size: 0.58rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.08rem 0.4rem; border-radius: 0.26rem; background: var(--fs-panel-section-bg, rgba(128,128,128,0.12)); }
.hd-side { flex-shrink: 0; display: flex; align-items: baseline; gap: 1.1rem; }
.hd-count { font-size: 0.72rem; opacity: 0.8; font-variant-numeric: tabular-nums; }
.hd-count b { font-size: 0.88rem; font-weight: 800; }
.hd-count--dim { opacity: 0.55; }
.hd-when { font-size: 0.7rem; opacity: 0.6; color: inherit; text-decoration: none; }
.hd-when:hover { opacity: 1; text-decoration: underline; }
.hd-empty { padding: 1.6rem 1rem; text-align: center; opacity: 0.7; }
.hd-pages { margin-top: 0.8rem; font-size: 0.76rem; opacity: 0.8; display: flex; gap: 0.8rem; }
.hd-pages a { color: var(--fs-link-color, inherit); font-weight: 600; text-decoration: none; }
.hd-pages a:hover { text-decoration: underline; }
@media (max-width: 760px) {
    .hd-features { grid-template-columns: 1fr; }
    .hd-side { flex-direction: column; align-items: flex-end; gap: 0.15rem; }
    .hd-count--dim { display: none; }
}

4 replies 30 Read more →
Cedric ·Customization Help ·3 months ago
Custom Templates: Topic view template

Here's a few examples for the topic view:

Template:

<div class="dk">
{{#each replies}}
<div class="reply">
    <div class="dk-post{{#if this.reply.is_best}} dk-post--best{{/if}}{{#if this.reply.is_op}} dk-post--op{{/if}}">
        <div class="dk-head">
            <a href="{{ this.user.profile_url }}" class="dk-avatar">
                <img src="{{ this.user.avatar_url }}" alt="{{ this.user.username }}" />
            </a>
            <div class="dk-id">
                <span class="dk-name">
                    <a href="{{ this.user.profile_url }}">{{ this.user.username }}</a>
                    {{#if this.reply.is_op}}<span class="dk-tag dk-tag--op">Author</span>{{/if}}
                    {{#if this.user.is_staff}}<span class="dk-tag dk-tag--staff">{{ this.user.group_name }}</span>{{/if}}
                    {{#if this.reply.is_best}}<span class="dk-tag dk-tag--best"><span class="fa-solid fa-circle-check"></span> Best answer</span>{{/if}}
                </span>
                <span class="dk-sub">{{ this.user.num_posts }} posts <span class="dk-sep">·</span> joined {{ this.user.joined_long }}</span>
            </div>
            <a href="{{ this.reply.permalink }}" class="dk-time">{{ this.reply.created_at }}</a>
            {{{ this.reply.meta }}}
        </div>
        <div class="dk-body">
            {{{ this.reply.body }}}
        </div>
        <div class="dk-actions">
            {{{ this.reply.actions }}}
        </div>
    </div>
</div>
{{/each}}
{{#if pagination.has_pages}}
<div class="dk-pages">
    Page {{ pagination.current_page }} of {{ pagination.last_page }}
    {{#if pagination.prev_url}}<a href="{{ pagination.prev_url }}">Previous</a>{{/if}}
    {{#if pagination.next_url}}<a href="{{ pagination.next_url }}">Next</a>{{/if}}
</div>
{{/if}}
</div>

CSS:

.dk { display: flex; flex-direction: column; gap: 0.9rem; padding: 0.9rem; }
.dk-post { background: var(--fs-reply-bg, rgba(128,128,128,0.03)); border: 1px solid var(--fs-panel-border, rgba(128,128,128,0.2)); border-radius: 0.7rem; overflow: hidden; }
.dk-post--best { border-color: var(--fs-panel-footer-accent, rgba(128,128,128,0.45)); box-shadow: 0 0 0 1px var(--fs-panel-footer-accent, transparent); }
.dk-head { display: flex; align-items: center; gap: 0.7rem; padding: 0.75rem 1rem; border-bottom: 1px solid var(--fs-forum-divider, rgba(128,128,128,0.13)); background: var(--fs-reply-details-bg, rgba(128,128,128,0.04)); }
.dk-avatar { flex-shrink: 0; line-height: 0; }
.dk-avatar img { width: 2.4rem; height: 2.4rem; border-radius: 50%; object-fit: cover; }
.dk-id { flex: 1; min-width: 0; }
.dk-name { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; }
.dk-name a { font-size: 0.95rem; font-weight: 750; color: inherit; text-decoration: none; }
.dk-name a:hover { color: var(--fs-panel-footer-accent, inherit); }
.dk-tag { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.58rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.13rem 0.5rem; border-radius: 999px; }
.dk-tag--op { background: var(--fs-panel-section-bg, rgba(128,128,128,0.12)); }
.dk-tag--staff { color: #fff; background: var(--fs-panel-footer-accent, #35a377); }
.dk-tag--best { color: var(--fs-panel-footer-accent, inherit); border: 1px solid var(--fs-panel-footer-accent, rgba(128,128,128,0.4)); }
.dk-sub { display: block; font-size: 0.68rem; opacity: 0.55; margin-top: 0.1rem; }
.dk-sep { opacity: 0.6; margin: 0 0.1rem; }
.dk-time { flex-shrink: 0; font-size: 0.72rem; opacity: 0.6; color: inherit; text-decoration: none; }
.dk-time:hover { opacity: 1; text-decoration: underline; }
.dk-head .post-layout-meta { flex-shrink: 0; display: inline-flex; align-items: center; gap: 0.5rem; }
.dk-head .post-layout-meta__number { font-size: 0.72rem; font-weight: 700; opacity: 0.55; color: inherit; text-decoration: none; }
.dk-body { padding: 1rem 1.1rem 0.5rem; }
.dk-actions { display: flex; padding: 0.5rem 1rem 0.7rem; }
.dk-actions .reply-action-group { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.dk-actions .reply-action-group--reply { margin-left: auto; }
.dk-pages { padding: 0.2rem 0.3rem; font-size: 0.78rem; opacity: 0.8; display: flex; gap: 0.8rem; }
.dk-pages a { color: var(--fs-link-color, inherit); font-weight: 600; text-decoration: none; }
.dk-pages a:hover { text-decoration: underline; }
@media (max-width: 640px) {
    .dk-sub { display: none; }
}

Template:

<div class="td">
{{#each replies}}
<div class="reply">
    <div class="td-msg{{#if this.reply.is_op}} td-msg--op{{/if}}{{#if this.reply.is_best}} td-msg--best{{/if}}">
        <a href="{{ this.user.profile_url }}" class="td-avatar">
            <img src="{{ this.user.avatar_url }}" alt="{{ this.user.username }}" />
        </a>
        <div class="td-col">
            <div class="td-top">
                <a href="{{ this.user.profile_url }}" class="td-name">{{ this.user.username }}</a>
                {{#if this.user.is_staff}}<span class="td-staff">{{ this.user.group_name }}</span>{{/if}}
                {{#if this.reply.is_op}}<span class="td-op">OP</span>{{/if}}
                <a href="{{ this.reply.permalink }}" class="td-time">{{ this.reply.created_at }}</a>
                {{{ this.reply.meta }}}
            </div>
            <div class="td-bubble">
                {{#if this.reply.is_best}}<div class="td-best"><span class="fa-solid fa-circle-check"></span> Marked as best answer</div>{{/if}}
                <div class="td-body">
                    {{{ this.reply.body }}}
                </div>
            </div>
            <div class="td-actions">
                {{{ this.reply.actions }}}
            </div>
        </div>
    </div>
</div>
{{/each}}
{{#if pagination.has_pages}}
<div class="td-pages">
    Page {{ pagination.current_page }} of {{ pagination.last_page }}
    {{#if pagination.prev_url}}<a href="{{ pagination.prev_url }}">Previous</a>{{/if}}
    {{#if pagination.next_url}}<a href="{{ pagination.next_url }}">Next</a>{{/if}}
</div>
{{/if}}
</div>

CSS:

.td { display: flex; flex-direction: column; gap: 1.1rem; padding: 1rem; }
.td-msg { display: flex; gap: 0.75rem; }
.td-avatar { flex-shrink: 0; line-height: 0; margin-top: 0.15rem; }
.td-avatar img { width: 2.3rem; height: 2.3rem; border-radius: 50%; object-fit: cover; }
.td-col { flex: 1; min-width: 0; }
.td-top { display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.3rem; }
.td-name { font-size: 0.9rem; font-weight: 750; color: inherit; text-decoration: none; }
.td-name:hover { color: var(--fs-panel-footer-accent, inherit); }
.td-staff { font-size: 0.56rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: #fff; background: var(--fs-panel-footer-accent, #35a377); padding: 0.12rem 0.45rem; border-radius: 999px; }
.td-op { font-size: 0.56rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; padding: 0.12rem 0.45rem; border-radius: 999px; background: var(--fs-panel-section-bg, rgba(128,128,128,0.12)); }
.td-time { font-size: 0.68rem; opacity: 0.55; color: inherit; text-decoration: none; }
.td-time:hover { opacity: 1; text-decoration: underline; }
.td-top .post-layout-meta { margin-left: auto; display: inline-flex; align-items: center; gap: 0.5rem; }
.td-top .post-layout-meta__number { font-size: 0.7rem; font-weight: 700; opacity: 0.5; color: inherit; text-decoration: none; }
.td-bubble { background: var(--fs-reply-bg, rgba(128,128,128,0.05)); border: 1px solid var(--fs-panel-border, rgba(128,128,128,0.16)); border-radius: 0.25rem 0.9rem 0.9rem 0.9rem; padding: 0.9rem 1rem; }
.td-msg--op .td-bubble { border-left: 3px solid var(--fs-panel-footer-accent, rgba(128,128,128,0.45)); }
.td-msg--best .td-bubble { border-color: var(--fs-panel-footer-accent, rgba(128,128,128,0.45)); }
.td-best { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.68rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--fs-panel-footer-accent, inherit); margin-bottom: 0.5rem; }
.td-actions { display: flex; margin-top: 0.35rem; opacity: 0.45; transition: opacity 0.15s ease; }
.td-msg:hover .td-actions, .td-actions:focus-within { opacity: 1; }
.td-actions .reply-action-group { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
.td-actions .reply-action-group--reply { margin-left: auto; }
.td-actions .btn { font-size: 0.68rem; padding: 0.22rem 0.55rem; }
.td-pages { padding: 0.2rem 0.3rem; font-size: 0.78rem; opacity: 0.8; display: flex; gap: 0.8rem; }
.td-pages a { color: var(--fs-link-color, inherit); font-weight: 600; text-decoration: none; }
.td-pages a:hover { text-decoration: underline; }
@media (prefers-reduced-motion: reduce) { .td-actions { transition: none; } }

Template:

<div class="tx">
{{#each replies}}
<div class="reply">
    <div class="tx-post{{#if this.reply.is_best}} tx-post--best{{/if}}">
        <span class="tx-beam"></span>
        <div class="tx-rail">
            <span class="tx-hex">
                <img src="{{ this.user.avatar_url }}" alt="{{ this.user.username }}" />
            </span>
            <a href="{{ this.user.profile_url }}" class="tx-name">{{ this.user.username }}</a>
            {{#if this.user.is_staff}}<span class="tx-staff">{{ this.user.group_name }}</span>{{/if}}
            {{#if this.reply.is_op}}<span class="tx-origin">Origin</span>{{/if}}
            <span class="tx-data">{{ this.user.num_posts }} posts</span>
            <span class="tx-data">Est. {{ this.user.joined_long }}</span>
        </div>
        <div class="tx-main">
            <div class="tx-bar">
                {{#if this.reply.is_best}}<span class="tx-best"><span class="fa-solid fa-circle-check"></span> Verified answer</span>{{/if}}
                <a href="{{ this.reply.permalink }}" class="tx-time">{{ this.reply.created_at }}</a>
                {{{ this.reply.meta }}}
            </div>
            <div class="tx-body">
                {{{ this.reply.body }}}
            </div>
            <div class="tx-actions">
                {{{ this.reply.actions }}}
            </div>
        </div>
    </div>
</div>
{{/each}}
{{#if pagination.has_pages}}
<div class="tx-pages">
    Page {{ pagination.current_page }} of {{ pagination.last_page }}
    {{#if pagination.prev_url}}<a href="{{ pagination.prev_url }}">Previous</a>{{/if}}
    {{#if pagination.next_url}}<a href="{{ pagination.next_url }}">Next</a>{{/if}}
</div>
{{/if}}
</div>

CSS:

.tx { display: flex; flex-direction: column; gap: 1.1rem; padding: 0.9rem; counter-reset: txsig; }
.tx-post { counter-increment: txsig; position: relative; display: flex; background: #0a0d18; border: 1px solid rgba(122, 92, 255, 0.3); border-radius: 0.8rem; color: rgba(255, 255, 255, 0.88); overflow: hidden; }
.tx-post::before { content: ""; position: absolute; inset: 0; background: repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 3px); pointer-events: none; z-index: 0; }
.tx-post::after { content: "SIG " counter(txsig, decimal-leading-zero); position: absolute; top: 0.5rem; right: 0.9rem; font-size: 2.6rem; font-weight: 900; letter-spacing: -0.04em; color: rgba(255, 255, 255, 0.05); pointer-events: none; font-variant-numeric: tabular-nums; }
.tx-beam { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: linear-gradient(90deg, #59f3ff, #7a5cff, #ff5ee7, #59f3ff); background-size: 300% 100%; animation: txFlow 5s linear infinite; z-index: 1; }
.tx-post--best { border-color: #59f3ff; box-shadow: 0 0 26px rgba(89, 243, 255, 0.18); }
.tx-rail { position: relative; z-index: 1; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 0.3rem; width: 9.5rem; padding: 1.3rem 0.8rem; border-right: 1px solid rgba(122, 92, 255, 0.25); text-align: center; }
.tx-hex { display: block; width: 3.6rem; height: 3.6rem; padding: 2px; background: linear-gradient(160deg, #59f3ff, #7a5cff); clip-path: polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%); }
.tx-hex img { width: 100%; height: 100%; object-fit: cover; clip-path: polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%); }
.tx-name { margin-top: 0.35rem; font-size: 0.92rem; font-weight: 800; color: #fff; text-decoration: none; }
.tx-name:hover { color: #59f3ff; text-shadow: 0 0 14px rgba(89, 243, 255, 0.8); }
.tx-staff { font-size: 0.54rem; font-weight: 800; letter-spacing: 0.2em; text-transform: uppercase; color: #fff; text-shadow: 0.08em 0 0 rgba(255, 94, 231, 0.85), -0.08em 0 0 rgba(89, 243, 255, 0.85); }
.tx-origin { font-size: 0.52rem; font-weight: 800; letter-spacing: 0.16em; text-transform: uppercase; color: #0a0d18; background: linear-gradient(90deg, #59f3ff, #ff5ee7); padding: 0.15rem 0.55rem; border-radius: 999px; }
.tx-data { font-size: 0.58rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: rgba(255, 255, 255, 0.4); }
.tx-main { position: relative; z-index: 1; flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tx-bar { display: flex; align-items: center; gap: 0.7rem; padding: 0.65rem 1rem; border-bottom: 1px solid rgba(122, 92, 255, 0.22); font-size: 0.74rem; }
.tx-best { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.62rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: #59f3ff; }
.tx-time { color: rgba(255, 255, 255, 0.55); text-decoration: none; }
.tx-time:hover { color: #59f3ff; }
.tx-bar .post-layout-meta { margin-left: auto; display: inline-flex; align-items: center; gap: 0.5rem; }
.tx-bar .post-layout-meta__number { font-size: 0.72rem; font-weight: 800; color: rgba(255, 255, 255, 0.45); text-decoration: none; }
.tx-bar .post-layout-meta__number:hover { color: #59f3ff; }
.tx-body { padding: 1rem 1.1rem 0.5rem; }
.tx-body .content { color: rgba(255, 255, 255, 0.88); }
.tx-body .content a { color: #59f3ff; }
.tx-body .content blockquote { background: rgba(122, 92, 255, 0.12); border-left: 3px solid #7a5cff; }
.tx-body .editby { color: rgba(255, 255, 255, 0.45); }
.tx-body .signature { border-top: 1px solid rgba(122, 92, 255, 0.2); color: rgba(255, 255, 255, 0.55); }
.tx-actions { display: flex; padding: 0.5rem 1rem 0.75rem; margin-top: auto; }
.tx-actions .reply-action-group { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.tx-actions .reply-action-group--reply { margin-left: auto; }
.tx-actions .btn { background: transparent; border: 1px solid rgba(122, 92, 255, 0.4); color: rgba(255, 255, 255, 0.75); }
.tx-actions .btn:hover { border-color: #59f3ff; color: #59f3ff; box-shadow: 0 0 12px rgba(89, 243, 255, 0.25); }
.tx-pages { padding: 0.2rem 0.3rem; font-size: 0.78rem; opacity: 0.85; display: flex; gap: 0.8rem; }
.tx-pages a { color: var(--fs-link-color, inherit); font-weight: 600; text-decoration: none; }
.tx-pages a:hover { text-decoration: underline; }
@keyframes txFlow { to { background-position: 300% 0; } }
@media (prefers-reduced-motion: reduce) { .tx-beam { animation: none; } }
@media (max-width: 760px) {
    .tx-post { flex-direction: column; }
    .tx-rail { flex-direction: row; width: 100%; padding: 0.8rem 1rem; border-right: 0; border-bottom: 1px solid rgba(122, 92, 255, 0.25); text-align: left; align-items: center; gap: 0.6rem; }
    .tx-hex { width: 2.4rem; height: 2.4rem; }
    .tx-name { margin-top: 0; }
    .tx-data { display: none; }
    .tx-post::after { font-size: 1.7rem; }
}

5 replies 30 Read more →
Cedric ·Customization Help ·3 months ago
Custom Templates: Board index template

Customizing the board index template allows you to further style your forum to your preference. Below a few examples:

Template:

<div class="bx">
{{#each categories}}
<div class="bx-cat">
    <div class="bx-cat-head">
        <a href="{{ this.url }}" class="bx-cat-title">{{ this.name }}</a>
        <span class="bx-cat-line"></span>
    </div>
    <div class="bx-grid">
        {{#each this.forums}}
        <div class="bx-card{{#unless this.is_read}} bx-card--unread{{/unless}}">
            <div class="bx-top">
                <span class="bx-ico">
                    {{#if this.marker_image_url}}<img src="{{ this.marker_image_url }}" alt="{{ this.name }}" />{{else}}<span class="{{ this.marker_icon | default:fa-regular fa-comments }}"></span>{{/if}}
                </span>
                {{#unless this.is_read}}<span class="bx-new">New</span>{{/unless}}
            </div>
            <div class="bx-name"><a href="{{ this.url }}">{{ this.name }}</a></div>
            <div class="bx-desc">{{{ this.description }}}</div>
            {{#if this.subforums}}
            <div class="bx-subs">
                {{#each this.subforums}}<a class="bx-sub{{#unless this.is_read}} bx-sub--unread{{/unless}}" href="{{ this.url }}">{{ this.name }}</a>{{/each}}
            </div>
            {{/if}}
            <div class="bx-meta">
                <span class="bx-chip"><span class="fa-regular fa-comments"></span> {{ this.topics_count }}</span>
                <span class="bx-chip"><span class="fa-regular fa-message"></span> {{ this.replies_count }}</span>
            </div>
            {{#if this.last_post.author}}
            <div class="bx-last">
                <span class="bx-last-av"><img src="{{ this.last_post.avatar_url }}" alt="{{ this.last_post.author }}" /></span>
                <span class="bx-last-txt">
                    <a href="{{ this.last_post.url }}">{{ this.last_post.title | limit:34 }}</a>
                    <span class="bx-last-meta">{{ this.last_post.author }} · {{ this.last_post.date }}</span>
                </span>
            </div>
            {{else}}
            <div class="bx-last bx-last--empty">No posts yet</div>
            {{/if}}
        </div>
        {{/each}}
    </div>
</div>
{{/each}}
<div class="bx-stats">
    <span class="bx-stat"><span class="fa-regular fa-comments"></span> <b>{{ stats.topics }}</b> topics</span>
    <span class="bx-stat"><span class="fa-regular fa-message"></span> <b>{{ stats.replies }}</b> posts</span>
    <span class="bx-stat"><span class="fa-regular fa-user"></span> <b>{{ stats.members }}</b> members</span>
    <span class="bx-stat"><span class="bx-pulse"></span> <b>{{ stats.online }}</b> online</span>
    <span class="bx-stat bx-stat--right">Newest: <a href="{{ stats.latest_member_url }}">{{ stats.latest_member }}</a></span>
</div>
</div>

CSS:

.bx-cat + .bx-cat { margin-top: 1.6rem; }
.bx-cat-head { display: flex; align-items: center; gap: 0.8rem; margin-bottom: 0.7rem; }
.bx-cat-title { font-size: 0.72rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.16em; color: inherit; text-decoration: none; opacity: 0.85; }
.bx-cat-title:hover { color: var(--fs-panel-footer-accent, inherit); opacity: 1; }
.bx-cat-line { flex: 1; height: 1px; background: linear-gradient(90deg, var(--fs-panel-footer-accent, rgba(128,128,128,0.4)), transparent); }
.bx-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: 0.8rem; }
.bx-card { display: flex; flex-direction: column; padding: 0.9rem 0.95rem 0; background: var(--fs-panel-bg, rgba(128,128,128,0.05)); border: 1px solid var(--fs-panel-border, rgba(128,128,128,0.2)); border-radius: 0.8rem; transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; }
.bx-card:hover { transform: translateY(-3px); border-color: var(--fs-panel-footer-accent, rgba(128,128,128,0.4)); box-shadow: 0 10px 26px rgba(0, 0, 0, 0.12); }
.bx-card--unread { border-color: var(--fs-panel-footer-accent, rgba(128,128,128,0.45)); box-shadow: inset 3px 0 0 var(--fs-panel-footer-accent, transparent); }
.bx-top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 0.6rem; }
.bx-ico { display: flex; align-items: center; justify-content: center; width: 2.6rem; height: 2.6rem; border-radius: 0.6rem; background: var(--fs-panel-section-bg, rgba(128,128,128,0.12)); color: var(--fs-panel-footer-accent, inherit); font-size: 1.05rem; }
.bx-ico img { width: 2.6rem; height: 2.6rem; object-fit: contain; border-radius: 0.6rem; }
.bx-new { font-size: 0.56rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em; color: #fff; background: var(--fs-panel-footer-accent, #35a377); border-radius: 999px; padding: 0.2rem 0.55rem; }
.bx-name a { font-size: 0.95rem; font-weight: 700; color: inherit; text-decoration: none; }
.bx-name a:hover { color: var(--fs-panel-footer-accent, inherit); }
.bx-card--unread .bx-name a { color: var(--fs-panel-footer-accent, inherit); }
.bx-desc { font-size: 0.78rem; opacity: 0.68; line-height: 1.45; margin-top: 0.25rem; }
.bx-desc p { margin: 0; }
.bx-subs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.55rem; }
.bx-sub { font-size: 0.68rem; padding: 0.16rem 0.5rem; border: 1px solid var(--fs-panel-border, rgba(128,128,128,0.25)); border-radius: 999px; color: inherit; text-decoration: none; opacity: 0.75; }
.bx-sub:hover { border-color: var(--fs-panel-footer-accent, currentColor); opacity: 1; }
.bx-sub--unread { font-weight: 700; opacity: 1; border-color: var(--fs-panel-footer-accent, rgba(128,128,128,0.4)); }
.bx-meta { display: flex; gap: 0.4rem; margin-top: 0.7rem; }
.bx-chip { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.7rem; font-weight: 600; padding: 0.22rem 0.55rem; border-radius: 999px; background: var(--fs-panel-section-bg, rgba(128,128,128,0.1)); }
.bx-chip .fa-regular, .bx-chip .fa-solid { font-size: 0.66rem; opacity: 0.7; }
.bx-last { display: flex; align-items: center; gap: 0.5rem; margin: 0.75rem -0.95rem 0; padding: 0.6rem 0.95rem; border-top: 1px solid var(--fs-forum-divider, rgba(128,128,128,0.15)); min-width: 0; }
.bx-last-av { flex-shrink: 0; width: 1.6rem; height: 1.6rem; border-radius: 50%; overflow: hidden; line-height: 0; }
.bx-last-av img { width: 100%; height: 100%; object-fit: cover; }
.bx-last-txt { display: flex; flex-direction: column; min-width: 0; }
.bx-last-txt a { font-size: 0.75rem; font-weight: 600; color: inherit; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bx-last-txt a:hover { color: var(--fs-panel-footer-accent, inherit); }
.bx-last-meta { font-size: 0.66rem; opacity: 0.55; }
.bx-last--empty { font-size: 0.72rem; font-style: italic; opacity: 0.5; }
.bx-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 1.4rem; margin-top: 1.6rem; padding: 0.7rem 1rem; background: var(--fs-panel-bg, rgba(128,128,128,0.05)); border: 1px solid var(--fs-panel-border, rgba(128,128,128,0.2)); border-radius: 0.8rem; font-size: 0.78rem; }
.bx-stat { display: inline-flex; align-items: center; gap: 0.4rem; opacity: 0.85; }
.bx-stat b { font-weight: 800; }
.bx-stat a { color: var(--fs-panel-footer-accent, inherit); text-decoration: none; font-weight: 700; }
.bx-stat--right { margin-left: auto; }
.bx-pulse { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--fs-online-active, #30a661); animation: bxPulse 1.8s ease-in-out infinite; }
@keyframes bxPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
@media (prefers-reduced-motion: reduce) { .bx-pulse, .bx-card { animation: none; transition: none; } }
@media (max-width: 640px) { .bx-grid { grid-template-columns: 1fr; } .bx-stat--right { margin-left: 0; } }

Template:

<div class="at">
{{#each categories}}
<div class="at-cat">
    <div class="at-head">
        <a href="{{ this.url }}" class="at-title">{{ this.name }}</a>
        <span class="at-rule"></span>
    </div>
    <div class="at-grid">
        {{#each this.forums}}
        <div class="at-row{{#unless this.is_read}} at-row--unread{{/unless}}">
            <span class="at-tile">
                {{#if this.marker_image_url}}
                    <img src="{{ this.marker_image_url }}" alt="{{ this.name }}" />
                {{else}}
                    <span class="{{ this.marker_icon | default:fa-regular fa-comments }}"></span>
                {{/if}}
            </span>
            <div class="at-body">
                <div class="at-name">
                    <a href="{{ this.url }}">{{ this.name }}</a>
                    {{#unless this.is_read}}<span class="at-dot"></span>{{/unless}}
                </div>
                <div class="at-desc">{{{ this.description }}}</div>
                <div class="at-meta"><b>{{ this.topics_count }}</b> topics <span class="at-sep">·</span> <b>{{ this.replies_count }}</b> replies</div>
                {{#if this.subforums}}
                <div class="at-subs">
                    {{#each this.subforums}}
                        <a href="{{ this.url }}" class="at-sub{{#unless this.is_read}} at-sub--unread{{/unless}}">{{ this.name }}</a>
                    {{/each}}
                </div>
                {{/if}}
                {{#if this.last_post.author}}
                <div class="at-last">
                    <img src="{{ this.last_post.avatar_url }}" alt="{{ this.last_post.author }}" />
                    <span class="at-last-txt">
                        <a href="{{ this.last_post.url }}">{{ this.last_post.title | limit:38 }}</a>
                        <span class="at-last-meta">{{ this.last_post.author }} <span class="at-sep">·</span> {{ this.last_post.date }}</span>
                    </span>
                </div>
                {{/if}}
            </div>
        </div>
        {{/each}}
    </div>
</div>
{{/each}}
<div class="at-stats">
    <span class="at-cell"><b>{{ stats.topics }}</b><span>Topics</span></span>
    <span class="at-cell"><b>{{ stats.replies }}</b><span>Posts</span></span>
    <span class="at-cell"><b>{{ stats.members }}</b><span>Members</span></span>
    <span class="at-cell"><b>{{ stats.online }}</b><span>Online</span></span>
    <span class="at-cell at-cell--new"><b><a href="{{ stats.latest_member_url }}">{{ stats.latest_member }}</a></b><span>Newest member</span></span>
</div>
</div>

CSS:

.at-cat + .at-cat { margin-top: 1.8rem; }
.at-head { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.85rem; }
.at-title { font-size: 1.18rem; font-weight: 800; letter-spacing: -0.015em; color: inherit; text-decoration: none; }
.at-title:hover { color: var(--fs-panel-footer-accent, inherit); }
.at-rule { flex: 1; height: 3px; border-radius: 999px; background: linear-gradient(90deg, var(--fs-panel-footer-accent, rgba(128,128,128,0.4)), transparent 55%); }
.at-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; }
.at-row { display: flex; gap: 0.85rem; padding: 0.95rem 1rem; background: var(--fs-panel-bg, rgba(128,128,128,0.04)); border: 1px solid var(--fs-panel-border, rgba(128,128,128,0.2)); border-radius: 0.65rem; transition: border-color 0.14s ease, box-shadow 0.14s ease; }
.at-row:hover { border-color: var(--fs-panel-footer-accent, rgba(128,128,128,0.45)); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.07); }
.at-row--unread { box-shadow: inset 3px 0 0 var(--fs-panel-footer-accent, currentColor); }
.at-tile { flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 2.7rem; height: 2.7rem; border-radius: 0.55rem; background: var(--fs-panel-section-bg, rgba(128,128,128,0.1)); color: var(--fs-panel-footer-accent, inherit); font-size: 1.05rem; }
.at-tile img { width: 2.7rem; height: 2.7rem; object-fit: contain; border-radius: 0.55rem; }
.at-body { flex: 1; min-width: 0; }
.at-name { display: flex; align-items: center; gap: 0.45rem; }
.at-name a { font-size: 0.98rem; font-weight: 700; letter-spacing: -0.01em; color: var(--fs-forum-link-color, inherit); text-decoration: none; }
.at-name a:hover { color: var(--fs-panel-footer-accent, inherit); }
.at-row--unread .at-name a { color: var(--fs-panel-footer-accent, inherit); }
.at-dot { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--fs-panel-footer-accent, currentColor); }
.at-desc { font-size: 0.77rem; opacity: 0.62; line-height: 1.45; margin-top: 0.1rem; }
.at-desc p { margin: 0; }
.at-meta { font-size: 0.72rem; opacity: 0.75; margin-top: 0.35rem; font-variant-numeric: tabular-nums; }
.at-meta b { font-weight: 700; }
.at-sep { opacity: 0.45; margin: 0 0.15rem; }
.at-subs { display: flex; flex-wrap: wrap; gap: 0.15rem 0.8rem; margin-top: 0.3rem; font-size: 0.72rem; }
.at-sub { color: inherit; opacity: 0.65; text-decoration: none; }
.at-sub:hover { opacity: 1; text-decoration: underline; }
.at-sub--unread { opacity: 1; font-weight: 700; color: var(--fs-panel-footer-accent, inherit); }
.at-last { display: flex; align-items: center; gap: 0.45rem; margin-top: 0.55rem; padding-top: 0.55rem; border-top: 1px solid var(--fs-forum-divider, rgba(128,128,128,0.13)); min-width: 0; }
.at-last img { flex-shrink: 0; width: 1.45rem; height: 1.45rem; border-radius: 50%; object-fit: cover; }
.at-last-txt { display: flex; flex-direction: column; min-width: 0; }
.at-last-txt a { font-size: 0.75rem; font-weight: 600; color: inherit; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.at-last-txt a:hover { color: var(--fs-panel-footer-accent, inherit); }
.at-last-meta { font-size: 0.65rem; opacity: 0.55; }
.at-stats { display: flex; flex-wrap: wrap; margin-top: 1.8rem; border: 1px solid var(--fs-panel-border, rgba(128,128,128,0.2)); border-radius: 0.65rem; overflow: hidden; background: var(--fs-panel-bg, rgba(128,128,128,0.04)); }
.at-cell { flex: 1 1 0; min-width: 7rem; display: flex; flex-direction: column; align-items: center; gap: 0.05rem; padding: 0.85rem 0.5rem; }
.at-cell + .at-cell { border-left: 1px solid var(--fs-forum-divider, rgba(128,128,128,0.14)); }
.at-cell b { font-size: 1.15rem; font-weight: 800; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
.at-cell b a { color: var(--fs-panel-footer-accent, inherit); text-decoration: none; font-size: 0.92rem; }
.at-cell b a:hover { text-decoration: underline; }
.at-cell span { font-size: 0.58rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; opacity: 0.55; }
.at-cell--new { flex: 1.3 1 0; }
@media (max-width: 900px) {
    .at-grid { grid-template-columns: 1fr; }
    .at-cell { min-width: 33%; }
}

Template:

<div class="ch">
{{#each categories}}
<div class="ch-cat">
    <div class="ch-spine">
        <a href="{{ this.url }}">{{ this.name }}</a>
    </div>
    <div class="ch-list">
        {{#each this.forums}}
        <div class="ch-row{{#unless this.is_read}} ch-row--unread{{/unless}}">
            <div class="ch-main">
                <div class="ch-name">
                    <a href="{{ this.url }}">{{ this.name }}</a>
                    {{#unless this.is_read}}<span class="ch-flag">New</span>{{/unless}}
                </div>
                <div class="ch-desc">{{{ this.description }}}</div>
                {{#if this.subforums}}
                <div class="ch-subs">
                    {{#each this.subforums}}
                        <a href="{{ this.url }}" class="ch-sub{{#unless this.is_read}} ch-sub--unread{{/unless}}">{{ this.name }}</a>
                    {{/each}}
                </div>
                {{/if}}
                {{#if this.last_post.author}}
                <div class="ch-last">
                    <img src="{{ this.last_post.avatar_url }}" alt="{{ this.last_post.author }}" />
                    <a href="{{ this.last_post.url }}">{{ this.last_post.title | limit:44 }}</a>
                    <span class="ch-last-meta">{{ this.last_post.author }} · {{ this.last_post.date }}</span>
                </div>
                {{/if}}
            </div>
            <div class="ch-stats">
                <span class="ch-stat"><b>{{ this.topics_count }}</b><span>topics</span></span>
                <span class="ch-stat"><b>{{ this.replies_count }}</b><span>replies</span></span>
            </div>
        </div>
        {{/each}}
    </div>
</div>
{{/each}}
<div class="ch-foot">
    <span class="ch-fstat"><b>{{ stats.topics }}</b> topics</span>
    <span class="ch-fslash"></span>
    <span class="ch-fstat"><b>{{ stats.replies }}</b> posts</span>
    <span class="ch-fslash"></span>
    <span class="ch-fstat"><b>{{ stats.members }}</b> members</span>
    <span class="ch-fslash"></span>
    <span class="ch-fstat"><b>{{ stats.online }}</b> online</span>
    <span class="ch-fslash"></span>
    <span class="ch-fstat">newest <a href="{{ stats.latest_member_url }}">{{ stats.latest_member }}</a></span>
</div>
</div>

CSS:

.ch-cat { display: flex; gap: 1.2rem; counter-reset: chrow; }
.ch-cat + .ch-cat { margin-top: 2.4rem; }
.ch-spine { flex-shrink: 0; display: flex; justify-content: center; width: 2rem; border-right: 3px solid var(--fs-panel-footer-accent, rgba(128,128,128,0.5)); padding-right: 0.6rem; }
.ch-spine a { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.72rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.28em; color: inherit; opacity: 0.55; text-decoration: none; white-space: nowrap; }
.ch-spine a:hover { opacity: 1; color: var(--fs-panel-footer-accent, inherit); }
.ch-list { flex: 1; min-width: 0; }
.ch-row { counter-increment: chrow; display: flex; align-items: flex-start; gap: 1.4rem; padding: 1.15rem 0.2rem; }
.ch-row::before { content: counter(chrow, decimal-leading-zero); font-size: 3rem; font-weight: 900; line-height: 1; letter-spacing: -0.04em; opacity: 0.14; font-variant-numeric: tabular-nums; flex-shrink: 0; min-width: 4.2rem; transition: opacity 0.15s ease, color 0.15s ease; }
.ch-row:hover::before { opacity: 0.85; color: var(--fs-panel-footer-accent, inherit); }
.ch-row--unread::before { opacity: 1; color: var(--fs-panel-footer-accent, inherit); }
.ch-row + .ch-row { border-top: 2px solid var(--fs-panel-border, rgba(128,128,128,0.2)); }
.ch-main { flex: 1; min-width: 0; }
.ch-name { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; }
.ch-name a { font-size: 1.18rem; font-weight: 800; letter-spacing: -0.02em; color: inherit; text-decoration: none; }
.ch-name a:hover { color: var(--fs-panel-footer-accent, inherit); }
.ch-row--unread .ch-name a { color: var(--fs-panel-footer-accent, inherit); }
.ch-flag { font-size: 0.56rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.12em; color: #fff; background: var(--fs-panel-footer-accent, #35a377); padding: 0.16rem 0.5rem; }
.ch-desc { font-size: 0.8rem; opacity: 0.62; line-height: 1.5; margin-top: 0.25rem; max-width: 36rem; }
.ch-desc p { margin: 0; }
.ch-subs { display: flex; flex-wrap: wrap; gap: 0.2rem 0.9rem; margin-top: 0.4rem; font-size: 0.73rem; }
.ch-sub { color: inherit; opacity: 0.65; text-decoration: none; }
.ch-sub:hover { opacity: 1; text-decoration: underline; }
.ch-sub--unread { opacity: 1; font-weight: 700; color: var(--fs-panel-footer-accent, inherit); }
.ch-last { display: flex; align-items: center; gap: 0.45rem; margin-top: 0.55rem; min-width: 0; font-size: 0.75rem; }
.ch-last img { flex-shrink: 0; width: 1.4rem; height: 1.4rem; border-radius: 50%; object-fit: cover; }
.ch-last a { font-weight: 600; color: inherit; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ch-last a:hover { text-decoration: underline; }
.ch-last-meta { flex-shrink: 0; opacity: 0.55; font-size: 0.68rem; }
.ch-stats { flex-shrink: 0; display: flex; gap: 1.4rem; padding-top: 0.35rem; }
.ch-stat { display: flex; flex-direction: column; align-items: flex-end; }
.ch-stat b { font-size: 1.05rem; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
.ch-stat span { font-size: 0.58rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.5; }
.ch-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 0.9rem; margin-top: 2.4rem; padding-top: 1rem; border-top: 3px solid var(--fs-panel-footer-accent, rgba(128,128,128,0.5)); font-size: 0.85rem; }
.ch-fstat b { font-size: 1.25rem; font-weight: 900; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.ch-fstat a { color: var(--fs-panel-footer-accent, inherit); font-weight: 800; text-decoration: none; }
.ch-fstat a:hover { text-decoration: underline; }
.ch-fslash { width: 2px; height: 1.3rem; background: var(--fs-panel-border, rgba(128,128,128,0.3)); transform: skewX(-18deg); }
@media (max-width: 768px) {
    .ch-cat { gap: 0.7rem; }
    .ch-spine { width: 1.4rem; padding-right: 0.4rem; }
    .ch-row { gap: 0.8rem; }
    .ch-row::before { font-size: 1.9rem; min-width: 2.6rem; }
    .ch-stats { display: none; }
}

More examples soon.

3 replies 18 Read more →
Cedric ·Customization Help ·3 months ago
Custom Templates: Post rail template

I realize sometimes things can get a little overwhelming. But possibilities within ForumSpark's customization are quite endless. Themes, widgets, posts, custom templates all have a pretty extended way possible in customization. Here are a few examples for the Post rail template:

For this example above, you'd set your post rail template like this:

<div class="cc">
    <div class="cc-band"></div>
    <div class="cc-avwrap">
        <img class="cc-av" src="{{ user.avatar_url }}" alt="{{ user.username }}" />
        {{#if user.is_online}}<span class="cc-online"></span>{{/if}}
    </div>
    {{#if user.is_staff}}<div><span class="cc-staff">{{ user.is_staff }}</span></div>{{/if}}
    <div class="cc-title">{{#if user.custom_title}}{{ user.custom_title }}{{else}}{{ user.rank }}{{/if}}</div>
    {{#if user.group_image_url}}
        <img class="cc-banner" src="{{ user.group_image_url }}" alt="{{ user.group_name }}" />
    {{else}}
        <span class="cc-group">{{ user.group_name }}</span>
    {{/if}}
    <div class="cc-stats">
        <div class="cc-stat"><b>{{ user.num_posts }}</b><span>Posts</span></div>
        <div class="cc-stat"><b>{{ user.reputation }}</b><span>Rep</span></div>
    </div>
    {{#if user.trust_level}}
        <div class="cc-chip"><span class="fa-solid fa-shield-halved"></span> {{ user.trust_level }}</div>
    {{/if}}
    {{#if user.credits}}
        <div class="cc-chip"><span class="fa-solid fa-coins"></span> {{ user.credits }}</div>
    {{/if}}
    <div class="cc-shelf">
        {{#each user.achievements}}
            {{#if this.icon}}
                <img class="cc-ach" src="{{ this.icon }}" alt="{{ this.name }}" />
            {{else}}
                <span class="cc-ach-fallback fa-solid fa-trophy"></span>
            {{/if}}
        {{/each}}
    </div>
    <div class="cc-foot"><span class="fa-regular fa-calendar"></span> {{ user.joined_long }}{{#if user.location}} · <span class="fa-solid fa-location-dot"></span> {{ user.location }}{{/if}}</div>
</div>

And put this in Custom CSS:

.cc { margin-top: 0.2rem; }
.cc-band { height: 2.7rem; margin: 0 -0.3rem; border-radius: 0.6rem; background: linear-gradient(135deg, var(--fs-panel-title-from, #003049), var(--fs-panel-footer-accent, #35a377)); }
.cc-avwrap { position: relative; display: inline-block; margin-top: -2rem; }
.cc-av { width: 4.6rem; height: 4.6rem; object-fit: cover; border-radius: 50%; border: 3px solid var(--fs-panel-section-bg, #f8f9fe); box-shadow: 0 5px 14px rgba(0, 0, 0, 0.22); }
.cc-online { position: absolute; bottom: 0.25rem; right: 0.25rem; width: 0.8rem; height: 0.8rem; border-radius: 50%; background: var(--fs-online-active, #30a661); border: 2px solid var(--fs-panel-section-bg, #fff); }
.cc-staff { display: inline-block; margin-top: 0.3rem; font-size: 0.55rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: #fff; background: var(--fs-panel-footer-accent, #35a377); border-radius: 999px; padding: 0.16rem 0.6rem; }
.cc-title { margin-top: 0.3rem; font-size: 0.68rem; font-style: italic; opacity: 0.75; }
.cc-banner { margin: 0.4rem auto 0; max-width: 100%; }
.cc-group { display: inline-block; margin-top: 0.35rem; font-size: 0.58rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; opacity: 0.6; }
.cc-stats { display: flex; gap: 0.35rem; margin-top: 0.55rem; }
.cc-stat { flex: 1; background: rgba(128, 128, 128, 0.1); border: 1px solid rgba(128, 128, 128, 0.16); border-radius: 0.55rem; padding: 0.4rem 0.15rem; }
.cc-stat b { display: block; font-size: 0.82rem; }
.cc-stat span { font-size: 0.52rem; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.6; }
.cc-chip { margin-top: 0.35rem; font-size: 0.64rem; background: rgba(128, 128, 128, 0.1); border: 1px solid rgba(128, 128, 128, 0.16); border-radius: 999px; padding: 0.25rem 0.5rem; }
.cc-chip .fa-solid { opacity: 0.7; margin-right: 0.2rem; font-size: 0.58rem; }
.cc-shelf { display: flex; justify-content: center; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.55rem; }
.cc-ach { width: 1.15rem; height: 1.15rem; object-fit: contain; }
.cc-ach-fallback { font-size: 0.9rem; color: #d9a520; }
.cc-foot { margin-top: 0.55rem; font-size: 0.58rem; opacity: 0.55; }
.cc-foot .fa-solid, .cc-foot .fa-regular { font-size: 0.52rem; }
@media (max-width: 1024px) {
    .cc-band, .cc-title, .cc-banner, .cc-group, .cc-stats, .cc-chip, .cc-shelf, .cc-foot, .cc-staff { display: none; }
    .cc-avwrap { margin-top: 0; }
    .cc-av { border-width: 2px; box-shadow: none; }
}

Another example:

Post rail code:

<div class="hc">
    <div class="hc-frame">
        <div class="hc-inner">
            <div class="hc-avhex">
                <img class="hc-av" src="{{ user.avatar_url }}" alt="{{ user.username }}" />
            </div>
            {{#if user.is_online}}<div class="hc-live"><span class="hc-live-dot"></span>Online</div>{{/if}}
            {{#if user.is_staff}}<div class="hc-staff">{{ user.is_staff }}</div>{{/if}}
            <div class="hc-title">{{#if user.custom_title}}{{ user.custom_title }}{{else}}{{ user.rank }}{{/if}}</div>
            <div class="hc-stat">
                <div class="hc-stat-top"><span>Posts</span><b>{{ user.num_posts }}</b></div>
                <div class="hc-bar"><span class="hc-fill hc-fill-a"></span></div>
            </div>
            <div class="hc-stat">
                <div class="hc-stat-top"><span>Rep</span><b>{{ user.reputation }}</b></div>
                <div class="hc-bar"><span class="hc-fill hc-fill-b"></span></div>
            </div>
            {{#if user.trust_level}}
            <div class="hc-stat">
                <div class="hc-stat-top"><span>Level</span><b>{{ user.trust_level }}</b></div>
                <div class="hc-bar"><span class="hc-fill hc-fill-c"></span></div>
            </div>
            {{/if}}
            <div class="hc-shelf">
                {{#each user.achievements}}
                    {{#if this.icon}}
                        <img class="hc-ach" src="{{ this.icon }}" alt="{{ this.name }}" />
                    {{else}}
                        <span class="hc-ach-fb fa-solid fa-trophy"></span>
                    {{/if}}
                {{/each}}
            </div>
            <div class="hc-foot">Est. {{ user.joined_long }}</div>
        </div>
    </div>
</div>

Custom CSS:

.hc { margin-top: 0.3rem; }
.hc-frame { position: relative; border-radius: 0.85rem; padding: 2px; overflow: hidden; }
.hc-frame::before { content: ""; position: absolute; inset: -60%; background: conic-gradient(from 0deg, #ff5ee7, #ffd76e, #59f3ff, #7a5cff, #ff5ee7); animation: hcSpin 6s linear infinite; }
.hc-inner { position: relative; border-radius: 0.72rem; background: #0d0f1e; padding: 0.85rem 0.6rem 0.75rem; color: rgba(255, 255, 255, 0.9); overflow: hidden; }
.hc-inner::before { content: ""; position: absolute; inset: 0; background: repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 3px); pointer-events: none; }
.hc-inner::after { content: ""; position: absolute; top: -150%; left: -70%; width: 55%; height: 320%; transform: rotate(25deg); background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.14), transparent); animation: hcSheen 5.5s ease-in-out infinite; pointer-events: none; }
.hc-avhex { width: 4.5rem; height: 4.5rem; margin: 0.15rem auto 0; padding: 2px; background: linear-gradient(160deg, #59f3ff, #7a5cff); clip-path: polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%); }
.hc-av { width: 100%; height: 100%; object-fit: cover; clip-path: polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%); }
.hc-live { display: inline-flex; align-items: center; gap: 0.3rem; margin-top: 0.4rem; font-size: 0.5rem; font-weight: 800; letter-spacing: 0.18em; text-transform: uppercase; color: #3ddc84; }
.hc-live-dot { width: 0.42rem; height: 0.42rem; border-radius: 50%; background: #3ddc84; animation: hcPulse 1.6s ease-in-out infinite; }
.hc-staff { margin-top: 0.35rem; font-size: 0.6rem; font-weight: 800; letter-spacing: 0.22em; text-transform: uppercase; color: #fff; text-shadow: 0.08em 0 0 rgba(255, 94, 231, 0.85), -0.08em 0 0 rgba(89, 243, 255, 0.85); }
.hc-title { margin-top: 0.3rem; font-size: 0.64rem; font-style: italic; color: rgba(255, 255, 255, 0.55); }
.hc-stat { margin-top: 0.55rem; text-align: left; }
.hc-stat-top { display: flex; justify-content: space-between; align-items: baseline; }
.hc-stat-top span { font-size: 0.5rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: rgba(255, 255, 255, 0.42); }
.hc-stat-top b { font-size: 0.74rem; }
.hc-bar { height: 0.28rem; margin-top: 0.2rem; border-radius: 999px; background: rgba(255, 255, 255, 0.08); overflow: hidden; }
.hc-fill { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #59f3ff, #7a5cff, #ff5ee7, #59f3ff); background-size: 300% 100%; animation: hcFlow 4s linear infinite; }
.hc-fill-a { width: 78%; }
.hc-fill-b { width: 52%; animation-delay: -1.3s; }
.hc-fill-c { width: 90%; animation-delay: -2.6s; }
.hc-shelf { display: flex; justify-content: center; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.6rem; }
.hc-ach { width: 1.1rem; height: 1.1rem; object-fit: contain; }
.hc-ach-fb { font-size: 0.85rem; color: #ffd76e; }
.hc-foot { margin-top: 0.55rem; font-size: 0.5rem; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: rgba(255, 255, 255, 0.35); }
@keyframes hcSpin { to { transform: rotate(360deg); } }
@keyframes hcSheen { 0%, 55% { left: -70%; } 85%, 100% { left: 150%; } }
@keyframes hcPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
@keyframes hcFlow { to { background-position: 300% 0; } }
@media (prefers-reduced-motion: reduce) {
    .hc-frame::before, .hc-inner::after, .hc-live-dot, .hc-fill { animation: none; }
}
@media (max-width: 1024px) {
    .hc-live, .hc-staff, .hc-title, .hc-stat, .hc-shelf, .hc-foot { display: none; }
    .hc { margin-top: 0; }
    .hc-frame { border-radius: 50%; padding: 2px; }
    .hc-inner { padding: 0; background: transparent; border-radius: 50%; }
    .hc-inner::before, .hc-inner::after { display: none; }
    .hc-avhex { width: 2.6rem; height: 2.6rem; margin: 0; clip-path: none; border-radius: 50%; padding: 0; background: none; }
    .hc-av { clip-path: none; border-radius: 50%; }
}

AI is pretty good at providing you some code based on what you want. Give it a try and customize to your preference. I'll give some more examples for other custom templates soon.

1 reply 25 Read more →
Cedric ·Customization Help ·7 months ago
ForumSpark Theme Guide

I’ve just updated our documentation with a complete theming guide that covers everything you need to:

- Create your own custom theme
- Modify layouts and styling
- Adjust colors, branding, and UI elements
- Fully customize your ForumSpark board to match your vision

If you're building a new look or refining an existing one, this guide walks you through the entire process step by step. 🙂

Read it here:
V1: https://docs.forumspark.net/theme/

V2: https://forumspark.net/developers/docs/themes

If anything is unclear or you'd like additional examples added, let me know and I’ll expand the guide further.

Jamie ·Customization Help ·1 year ago
Extension Ideas: Auto Responding Bot

I just spent an hour or so using the ForumSpark API to prove how a (helper) bot could be created. In the demo I listen to incoming webhooks on the replies/create topic. The extension parses the reply to determine if the bot was tagged and which command to issue. If it was tagged and a command was given then it will automatically create an appropriate reply:

Obviously this is a toy example. But I could how something like this could have various applications, RPG forums to extend the playing experience, enabling moderator actions through posts instead of via clicks, requesting ai summaries of long topics so far.

If anyone is interested in making their own bot-like extension for ForumSpark I'd be happy to chat about how it all fits together.

Edit: a slightly more involved example allowing the bot to respond with Giphy GIFs:

Jamie ·Customization Help ·1 year ago
A brief money extension example

I figured it would be good to give an explanation of how I see larger-scale extensions being built. An example is probably the best way to explain. Please keep in mind this code is not production code but more just a basis to give you an idea.

The repo for this commentary is here: https://github.com/forumspark/money-extension-example

It is a Laravel Breeze (PHP) app but I will point to the most important things, they will be applicable in whichever language.

This example includes the ability for forum users to gain "money" or "credits" for making topics and posts. They have the option to make a donation to other users. This is as far as the example goes because it demonstrates listening to webhooks, showing some additional data for users and performing (a sort of) authenticated user action.

So on to it. The first thing we need to do is set up the forum in our application. The board owner needs to tell us their Board URL, an API token and some other basic configuration.

From that we'll store their details in our database and tell ForumSpark to start sending us webhooks: https://github.com/forumspark/money-extension-example/blob/master/app/Http/Controllers/ForumController.php#L18

<?php

       $response = Http::withHeaders([
            'Authorization' => 'Bearer ' . $request->api_key,
            'Accept' => 'application/json',
            'Content-Type' => 'application/json',
        ])
            ->post("https://{$request->board_url}.forumspark.net/api/v1/webhooks", [
                'topics' => ['topics/create', 'replies/create'],
                'url' => route('webhooks')
            ]);

        if (! $response->successful()) {
            throw ValidationException::withMessages(['api_key' => $response->body()]);
        }

        $signingSecret = $response->json('signing_secret');

        Forum::create([
            'user_id' => auth()->id(),
            'board_url' => $request->board_url,
            'api_key' => $request->api_key,
            'shop_name' => $request->shop_name,
            'shop_currency' => $request->shop_currency,
            'shop_currency_prefix' => $request->shop_currency_prefix,
            'shop_currency_suffix' => $request->shop_currency_suffix,
            'signing_secret' => $signingSecret,
        ]);

Here we make a request to ForumSpark using the provided API key to tell it to start sending webhooks relating to the topics replies/create and topics/create. That is, whenever someone creates a post or topic we want to be notified about it. And we tell ForumSpark to reach us at /webhooks of our application.

Next we need to start listening to those webhooks: https://github.com/forumspark/money-extension-example/blob/master/routes/web.php#L11

$board = $request->header('X-ForumSpark-Board');
    $signature = $request->header('X-ForumSpark-Signature');

    if (! $forum = Forum::whereBoardUrl(Str::of($board)->remove('.forumspark.net'))->first()) {
        // the board is not set up, we'll return a 200 response so FS does not keep
        // retrying a board that doesn't exist
        return;
    }

    $generatedHmac = hash_hmac('sha256', $request->getContent(), $forum->signing_secret);
    if (! hash_equals($generatedHmac, $signature)) {
        \Log::error("Generated HMAC does not match incoming signature.");
        abort(401);
    }

Firstly we compare the incoming signature with a generated signature on our end. We generate a hmac using the SHA256 algorithm, the request body and the shared secret we got from the first step.

$topic = $request->json('topic');
    if (! $user = FSUser::whereFsUserId($request->json('data.user_id'))->first()) {
        $user = FSUser::create([
            'forum_id' => $forum->id,
            'fs_user_id' => $request->json('data.user_id')
        ]);
    }

    // pull this from user config
    $topicMap = [
        'topics/create' => 5,
        'replies/create' => 2,
    ];

    $user->update(['credits' => $user->credits + $topicMap[$topic] ?? 0]);

Next we listen to the type of event and then update (or create) our user accordingly. We have to create because it is possible that we receive an event for a user we do not know about.

Now that we have a way for users to accumulate money, we need a way to show it. This is a combination of an api endpoint and a frontend mini profile widget.

The endpoint is simple: https://github.com/forumspark/money-extension-example/blob/master/routes/api.php#L11

There is a middleware that runs before hitting /api/* - this pulls the ForumSpark board URL from the headers and then scopes all data to that specific forum. So when we query /api/users/1 the user data relates to the user from the supplied board.

Otherwise, we look for the user and supply a default value in cases where the user does not exist or the actual data stored in our database (ie. the number of credits they have).

    if (! $user = FSUser::whereFsUserId($userId)->first()) {
        return [
            'credits' => 0,
            'exists' => false,
            'currency' => $request->forum->shop_currency,
            'prefix' => $request->forum->shop_currency_prefix,
            'suffix' => $request->forum->shop_currency_suffix,
        ];
    }

    return [
        'credits' => $user->credits,
        'currency' => $request->forum->shop_currency,
        'prefix' => $request->forum->shop_currency_prefix,
        'suffix' => $request->forum->shop_currency_suffix,
        'exists' => true
    ];

The frontend side of this simply makes a request to this endpoint: https://github.com/forumspark/money-extension-example/blob/master/public/credits.js

It will take a bit of explanation but its worthwhile looking at our docs regarding profile widgets: https://docs.forumspark.net/api/js.html#mini-profile-widgets

Essentially we setup a shared store (so that we dont make repeated requests for users on the same page). When that shared store is initialised, we fetch the user's credit info so that it can be presented in the render function.

fetchCredits() {
                    fetch(`${url}/users/${user.id}`, {headers: {'X-ForumSpark-Board': 'jamie-test.forumspark.net'}})
                        .then(res => res.json())
                        .then(json => {
                            this.currency = json.currency;
                            this.prefix = json.prefix;
                            this.suffix = json.suffix;
                            this.credits = json.credits
                            this.loading = false
                        })
                },

and

<div class="credits">
                <dl>
                    <div>
                        <dt x-text="${store}.currency"></dt>
                        <dd @click="show = true" x-text="${store}.loading ? 'loading' : ${store}.formattedCredits" />
                    </div>
                </dl>
            </div>

respectively.

The donation element takes a similar tactic, we take the frontend_secret (https://docs.forumspark.net/api/js.html#current-user-data) and send it along to a backend endpoint so that it can be checked against the API. If the frontend_secret matches and the user has enough credits then we can make the transaction.

fetch(`${url}/donate`, {
                        method: "POST",
                        headers: {
                            accept: 'application/json',
                            'content-type': 'application/json',
                            'X-ForumSpark-Board': 'jamie-test.forumspark.net'
                        },
                        body: JSON.stringify({
                          to_id: user.id,
                          secret: window.ForumSpark.currentUser.frontend_secret,
                          user_id: window.ForumSpark.currentUser.id,
                          amount
                        })
                    }).catch(err => console.error(err));

The javascript request

and the relevant backend endpoint: https://github.com/forumspark/money-extension-example/blob/master/routes/api.php#L32

The steps are as follows:

  1. Check the incoming secret against the ForumSpark API
  2. Check that the target user exists in the ForumSpark API
  3. Check that the user donating has enough credits
  4. Perform the transaction

This is all a bit of a rush (and Im not much of a writer) so I will likely expand on this and improve my explanations but I wanted to put this out there at least as a start for anyone interested.

Of course any questions then please ask away.

Note that I have plans to deprecate the frontend_secret system in favour of short-lived JWTs that can be generated and then verified against the FS API. It will be more secure that way :). But for now this is ok!

Please feel free to take the code, adapt as you need it.

ForumSpark © 2023 - 2026 · ForumSpark.net · Terms