Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions nodyx-frontend/src/lib/adminTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const ADMIN_THEMED_ROUTES: readonly string[] = [
'/admin/grades', '/admin/categories', '/admin/tags', '/admin/moderation', '/admin/audit-log',
// Modération unifiée (E2)
'/admin/reports',
// OctoGuard 2, livraison 3a
'/admin/octoguard', '/admin/octoguard/rules', '/admin/octoguard/sandbox',
]

// Sous-pages dynamiques déjà migrées (fiche d'un membre : /admin/members/<id>)
Expand Down
6 changes: 6 additions & 0 deletions nodyx-frontend/src/lib/components/octoguard/Icon.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script lang="ts">
// Icône au trait (24x24), couleur héritée du texte.
let { d, cls = 'og-ico' }: { d: string; cls?: string } = $props()
</script>

<svg class={cls} fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true"><path {d} /></svg>
42 changes: 42 additions & 0 deletions nodyx-frontend/src/lib/components/octoguard/LevelLegend.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<script lang="ts">
// « Comprendre les crans » : ce qui arrive au message, à son auteur et à
// l'équipe, cran par cran. Replié, on ne garde que le titre.
import { t } from '$lib/i18n'
import Icon from './Icon.svelte'
import { LEVELS, LEVEL_ICON } from '$lib/octoguard/rules'

const tFn = $derived($t)
const ROWS = [
{ key: 'message', icon: 'M21 12a8 8 0 01-11.6 7.1L4 20l1.2-4.4A8 8 0 1121 12z' },
{ key: 'author', icon: 'M12 4a4 4 0 100 8 4 4 0 000-8z M4 21a8 8 0 0116 0' },
{ key: 'team', icon: 'M9 4.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z M2.5 20a6.5 6.5 0 0113 0 M16 4.6a3.5 3.5 0 010 6.8 M21.5 20a6.5 6.5 0 00-4-6' },
]
let open = $state(true)
</script>

<details class="adm-card og-legend" bind:open>
<summary>
<span class="og-ic og-k-acc"><Icon d="M12 4v16 M6 20h12 M5 8h14 M5 8l-2.5 6a2.5 2.5 0 005 0z M19 8l-2.5 6a2.5 2.5 0 005 0z" /></span>
<span>
<span class="og-legend-title">{tFn('og2.legend_title')}</span>
<span class="og-help" style="display:block">{tFn('og2.legend_sub')}</span>
</span>
<Icon d="M9 6l6 6-6 6" cls="og-ico og-chev" />
</summary>
<div class="og-lgwrap">
<div class="og-lg">
<span></span>
{#each LEVELS as l (l)}
<span class="og-lg-h og-k-{l}"><Icon d={LEVEL_ICON[l]} />{tFn(`og2.lv_${l}`)}</span>
{/each}
{#each ROWS as row (row.key)}
<span class="og-lg-k"><Icon d={row.icon} />{tFn(`og2.legend_row_${row.key}`)}</span>
{#each LEVELS as l (l)}
<span class="og-lg-c">{tFn(`og2.legend_${row.key}_${l}`)}</span>
{/each}
{/each}
<span class="og-lg-tip">{tFn('og2.legend_start')}</span>
<span class="og-lg-scale"><span>{tFn('og2.legend_softer')}</span><i></i><span>{tFn('og2.legend_firmer')}</span></span>
</div>
</div>
</details>
31 changes: 31 additions & 0 deletions nodyx-frontend/src/lib/components/octoguard/LevelPicker.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<script lang="ts">
// Le sélecteur des crans, le même sur chaque règle : icône + couleur + nom,
// puis une phrase qui dit ce qui se passe à ce cran.
import { t } from '$lib/i18n'
import Icon from './Icon.svelte'
import { LEVELS, LEVEL_ICON, type Level, type Acting } from '$lib/octoguard/rules'

let { value, intended, name, busy = false, onpick }: {
value: Level
intended: Acting
name: string
busy?: boolean
onpick: (l: Level) => void
} = $props()
const tFn = $derived($t)
</script>

<div class="og-seg" role="radiogroup" aria-label={tFn('og2.level_label')}>
{#each LEVELS as l (l)}
<label class="og-k-{l}">
<input type="radio" {name} value={l} checked={value === l} disabled={busy} onchange={() => onpick(l)} />
<Icon d={LEVEL_ICON[l]} />
<span>{tFn(`og2.lv_${l}`)}</span>
</label>
{/each}
</div>
<p class="og-sentence og-k-{value}">
<b>{tFn(`og2.lv_${value}`)}</b> : {value === 'observe'
? tFn('og2.lv_say_observe', { would: tFn(`og2.lv_would_${intended}`) })
: tFn(`og2.lv_say_${value}`)}
</p>
109 changes: 109 additions & 0 deletions nodyx-frontend/src/lib/components/octoguard/RuleCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<script lang="ts">
// Une règle, en carte : ce qu'elle surveille, son cran, ce qu'elle a fait
// cette semaine. Interrupteur et cran s'enregistrent tout de suite (et
// reviennent en arrière si le serveur refuse).
import { t } from '$lib/i18n'
import Icon from './Icon.svelte'
import LevelPicker from './LevelPicker.svelte'
import WordsEditor from './WordsEditor.svelte'
import { og } from '$lib/octoguard/api'
import { levelOf, intendedOf, patchForLevel, describe, ruleIcon, scopeText, sanctionOf, durationText, type Rule, type Level } from '$lib/octoguard/rules'

let { rule, stats, token, onchange, onsettings }: {
rule: Rule
stats?: { acted: number; observed: number }
token: string | null
onchange: (r: Rule) => void
onsettings: (r: Rule) => void
} = $props()
const tFn = $derived($t)

let busy = $state(false)
let error = $state<string | null>(null)
const level = $derived(levelOf(rule))
const sanction = $derived(sanctionOf(rule))
const immune = $derived((rule.immunized_role_types ?? []).length > 0)

async function save(patch: Partial<Rule>, optimistic: Partial<Rule> = patch) {
const before = rule
onchange({ ...rule, ...optimistic })
busy = true
error = null
try {
const res = await og<{ rule: Rule }>(token, `/rules/${rule.id}`, 'PUT', patch)
onchange(res.rule)
} catch (e: any) {
onchange(before)
error = e?.code === 'RE2_REQUIRED' ? tFn('og2.err_re2') : tFn('og2.err_save')
} finally {
busy = false
}
}

const pickLevel = (l: Level) => save(patchForLevel(rule, l))
const toggle = (on: boolean) => save({ enabled: on })
const saveWords = (words: string[], exceptions: string[]) => save({ params: { ...rule.params, words, exceptions } })

const n = $derived(level === 'observe' ? stats?.observed ?? 0 : stats?.acted ?? 0)
</script>

<article class="adm-card og-rule {rule.enabled ? '' : 'og-off'} {rule.type === 'keywords' ? 'og-wide' : ''}" id="rule-{rule.id}">
<div class="og-rule-top">
<span class="og-rule-ico og-dim"><Icon d={ruleIcon(rule)} /></span>
<div class="og-t og-dim">
<h3>
{rule.name}
{#if rule.type === 'keywords'}
<button type="button" class="og-info" aria-label={tFn('og2.help')}><Icon d="M12 3a9 9 0 100 18 9 9 0 000-18z M12 11v5 M12 8h.01" /><span class="og-tip" role="tooltip">{tFn('og2.tip_keywords')}</span></button>
{:else if rule.conditions?.account_max_age_days}
<button type="button" class="og-info" aria-label={tFn('og2.help')}><Icon d="M12 3a9 9 0 100 18 9 9 0 000-18z M12 11v5 M12 8h.01" /><span class="og-tip" role="tooltip">{tFn('og2.tip_newcomers')}</span></button>
{/if}
{#if rule.customized}<span class="adm-pill">{tFn('og2.customized')}</span>{/if}
</h3>
<p>{describe(rule, tFn)}</p>
</div>
<label class="og-switch" title={tFn(rule.enabled ? 'og2.rule_on' : 'og2.rule_off')}>
<input type="checkbox" checked={rule.enabled} disabled={busy} onchange={(e) => toggle(e.currentTarget.checked)} aria-label={tFn('og2.rule_toggle', { name: rule.name })} />
<span class="og-track"></span>
</label>
</div>

<div class="og-dim">
<LevelPicker value={level} intended={intendedOf(rule)} name="lv-{rule.id}" {busy} onpick={pickLevel} />
</div>

{#if sanction && level === 'delete'}
<div class="og-note"><Icon d="M11 5L6 9H3v6h3l5 4z M22 9l-6 6 M16 9l6 6" />{tFn(`og2.sanction_${sanction}`, { dur: durationText(rule.action_duration, tFn) })}</div>
{/if}
{#if rule.escalation && (level === 'warn' || level === 'observe')}
<div class="og-note"><Icon d="M3 17l6-6 4 4 8-8 M15 7h6v6" />{tFn('og2.escalation_line', {
n: rule.escalation.warns_threshold,
d: rule.escalation.window_days,
what: tFn(`og2.sanction_short_${rule.escalation.action === 'ban_temp' ? 'ban_temp' : 'mute'}`, { dur: durationText(rule.escalation.duration, tFn) }),
})}</div>
{/if}

{#if rule.type === 'keywords'}
<WordsEditor words={rule.params?.words ?? []} exceptions={rule.params?.exceptions ?? []} {busy} onsave={saveWords} />
{/if}

<div class="og-rule-meta">
{#if n > 0}
<span><b>{n}</b> {tFn(level === 'observe' ? 'og2.stat_would' : 'og2.stat_took', { n })} · <button type="button" class="og-linkbtn" onclick={() => onsettings(rule)}>{tFn('og2.see_which')}</button></span>
{:else}
<span>{tFn('og2.stat_none')}</span>
{/if}
</div>

<div class="adm-actions" style="gap:0.375rem">
<span class="adm-pill">{scopeText(rule, tFn)}</span>
{#if immune}<span class="adm-pill">{tFn('og2.team_exempt')}</span>{/if}
</div>

{#if error}<p class="og-error" role="alert">{error}</p>{/if}

<div class="og-rule-foot">
<a class="adm-btn adm-btn--sm" href="/admin/octoguard/sandbox?rule={rule.id}"><Icon d="M9 3h6 M10 3v6L4.5 19A1.5 1.5 0 006 21h12a1.5 1.5 0 001.5-2L14 9V3 M7 15h10" />{tFn('og2.try')}</a>
<button type="button" class="adm-btn adm-btn--sm" onclick={() => onsettings(rule)}><Icon d="M4 7h10 M18 7h2 M4 17h4 M12 17h8 M16 5a2 2 0 100 4 2 2 0 000-4z M10 15a2 2 0 100 4 2 2 0 000-4z" />{tFn('og2.settings')}</button>
</div>
</article>
Loading
Loading