Fix Campaign log DB and rendering. Migrate mail rendering to virtual DOM API.

This commit is contained in:
Clément Pons 2025-11-19 17:14:45 +01:00
parent 7a40f8abac
commit c9f60d92ca
22 changed files with 237 additions and 177 deletions

View File

@ -103,7 +103,7 @@ export const campaignCharactersTable = table("campaign_characters", {
}, (table) => [primaryKey({ columns: [table.id, table.character] })]);
export const campaignLogsTable = table("campaign_logs", {
id: int().references(() => campaignTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
target: int().references(() => campaignCharactersTable.character, { onDelete: 'cascade', onUpdate: 'cascade' }),
target: int(),
timestamp: int({ mode: 'timestamp_ms' }).notNull(),
type: text({ enum: ['ITEM', 'CHARACTER', 'PLACE', 'EVENT', 'FIGHT', 'TEXT'] }),
details: text().notNull(),
@ -166,5 +166,4 @@ export const campaignCharacterRelation = relations(campaignCharactersTable, ({ o
}));
export const campaignLogsRelation = relations(campaignLogsTable, ({ one }) => ({
campaign: one(campaignTable, { fields: [campaignLogsTable.id], references: [campaignTable.id], }),
character: one(campaignCharactersTable, { fields: [campaignLogsTable.target], references: [campaignCharactersTable.character], }),
}));

View File

@ -21,6 +21,6 @@ export type Campaign = {
export type CampaignLog = {
target: number;
timestamp: Serialize<Date>;
type: 'ITEM' | 'CHARACTER' | 'PLACE' | 'EVENT' | 'FIGHT' | 'TEXT';
type: 'ITEM' | 'CHARACTER' | 'PLACE' | 'FIGHT' | 'TEXT';
details: string;
};

BIN
db.sqlite

Binary file not shown.

View File

@ -6,7 +6,7 @@ CREATE TABLE `campaign_logs` (
`details` text NOT NULL,
PRIMARY KEY(`id`, `from`, `timestamp`),
FOREIGN KEY (`id`) REFERENCES `campaign`(`id`) ON UPDATE cascade ON DELETE cascade,
FOREIGN KEY (`from`) REFERENCES `campaign_characters`(`id`) ON UPDATE cascade ON DELETE cascade
FOREIGN KEY (`from`)
);
--> statement-breakpoint
ALTER TABLE `campaign` ADD `status` text DEFAULT 'PREPARING';--> statement-breakpoint

View File

@ -7,7 +7,7 @@ CREATE TABLE `__new_campaign_logs` (
`details` text NOT NULL,
PRIMARY KEY(`id`, `from`, `timestamp`),
FOREIGN KEY (`id`) REFERENCES `campaign`(`id`) ON UPDATE cascade ON DELETE cascade,
FOREIGN KEY (`from`) REFERENCES `campaign_characters`(`character`) ON UPDATE cascade ON DELETE cascade
FOREIGN KEY (`from`)
);
--> statement-breakpoint
INSERT INTO `__new_campaign_logs`("id", "from", "timestamp", "type", "details") SELECT "id", "from", "timestamp", "type", "details" FROM `campaign_logs`;--> statement-breakpoint

View File

@ -7,7 +7,7 @@ CREATE TABLE `__new_campaign_logs` (
`details` text NOT NULL,
PRIMARY KEY(`id`, `target`, `timestamp`),
FOREIGN KEY (`id`) REFERENCES `campaign`(`id`) ON UPDATE cascade ON DELETE cascade,
FOREIGN KEY (`target`) REFERENCES `campaign_characters`(`character`) ON UPDATE cascade ON DELETE cascade
FOREIGN KEY (`target`)
);
--> statement-breakpoint
INSERT INTO `__new_campaign_logs`("id", "target", "timestamp", "type", "details") SELECT "id", "target", "timestamp", "type", "details" FROM `campaign_logs`;--> statement-breakpoint

View File

@ -116,19 +116,6 @@
],
"onDelete": "cascade",
"onUpdate": "cascade"
},
"campaign_logs_from_campaign_characters_id_fk": {
"name": "campaign_logs_from_campaign_characters_id_fk",
"tableFrom": "campaign_logs",
"tableTo": "campaign_characters",
"columnsFrom": [
"from"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {

View File

@ -116,19 +116,6 @@
],
"onDelete": "cascade",
"onUpdate": "cascade"
},
"campaign_logs_from_campaign_characters_character_fk": {
"name": "campaign_logs_from_campaign_characters_character_fk",
"tableFrom": "campaign_logs",
"tableTo": "campaign_characters",
"columnsFrom": [
"from"
],
"columnsTo": [
"character"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {

View File

@ -116,19 +116,6 @@
],
"onDelete": "cascade",
"onUpdate": "cascade"
},
"campaign_logs_from_campaign_characters_character_fk": {
"name": "campaign_logs_from_campaign_characters_character_fk",
"tableFrom": "campaign_logs",
"tableTo": "campaign_characters",
"columnsFrom": [
"from"
],
"columnsTo": [
"character"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {

View File

@ -116,19 +116,6 @@
],
"onDelete": "cascade",
"onUpdate": "cascade"
},
"campaign_logs_target_campaign_characters_character_fk": {
"name": "campaign_logs_target_campaign_characters_character_fk",
"tableFrom": "campaign_logs",
"tableTo": "campaign_characters",
"columnsFrom": [
"target"
],
"columnsTo": [
"character"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {

View File

@ -1,5 +1,4 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import vuePlugin from 'rollup-plugin-vue'
import fs from 'node:fs'
import path from 'node:path'
@ -136,10 +135,7 @@ export default defineNuxtConfig({
usePolling: true,
},
rollupConfig: {
external: ['bun'],
plugins: [
vuePlugin({ include: /\.vue$/, target: 'node' })
]
external: ['bun']
},
},
runtimeConfig: {
@ -168,7 +164,7 @@ export default defineNuxtConfig({
xssValidator: false,
},
sitemap: {
exclude: ['/admin/**', '/explore/edit', '/user/mailvalidated', '/user/changing-password', '/user/reset-password'],
exclude: ['/admin/**', '/explore/edit', '/user/mailvalidated', '/user/changing-password', '/user/reset-password', '/character/manage', '/campaign/create'],
sources: ['/api/__sitemap__/urls']
},
experimental: {
@ -190,6 +186,13 @@ export default defineNuxtConfig({
cert: fs.readFileSync(path.resolve(__dirname, 'localhost+1.pem')).toString('utf-8'),
}
},
vite: {
server: {
hmr: {
clientPort: 3000,
}
}
},
vue: {
compilerOptions: {
isCustomElement: (tag) => tag === 'iconify-icon',

View File

@ -1,11 +1,11 @@
import { dom } from '#shared/dom';
import { dom, type VirtualNode } from "#shared/dom.virtual.util";
export default function(content: HTMLElement[])
export default function(content: VirtualNode[])
{
return [dom('div', { style: 'margin-left: auto; margin-right: auto; width: 75%; font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; line-height: 1.5rem; color: #171717;' }, [
dom('div', { style: 'margin-left: auto; margin-right: auto; text-align: center;' }, [
dom('a', { style: 'display: inline-block;', attributes: { href: 'https://d-any.com' } }, [
dom('img', { style: 'display: block; height: 4rem; width: 4rem; margin-left: auto; margin-right: auto;', attributes: { src: 'https://d-any.com/logo.light.png', alt: 'Logo', title: 'd[any] logo', width: '64', height: '64' } })
dom('img', { style: 'display: block; height: 4rem; width: 4rem; margin-left: auto; margin-right: auto;', attributes: { src: 'https://d-any.com/logo.light.png', alt: 'Logo', title: 'd[any] logo', width: '64', height: '64' } }),
dom('span', { style: `margin-inline-end: 1rem; font-size: 1.5rem; color: black; text-decoration: none; line-height: 2rem; font-weight: 700; font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;`, text: 'd[any]' })
])
]),

View File

@ -1,16 +0,0 @@
<template>
<div style='margin-left: auto; margin-right: auto; width: 75%; font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-size: 1rem; line-height: 1.5rem; color: #171717;'>
<div style="margin-left: auto; margin-right: auto; text-align: center;">
<a style="display: inline-block;" href="https://d-any.com">
<img src="https://d-any.com/logo.light.png" alt="Logo" title="d[any] logo" width="64" height="64" style="display: block; height: 4rem; width: 4rem; margin-left: auto; margin-right: auto;" />
<span style="margin-inline-end: 1rem; font-size: 1.5rem; color: black; text-decoration: none; line-height: 2rem; font-weight: 700; font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;">d[any]</span>
</a>
</div>
<div style="padding: 1rem;">
<slot></slot>
</div>
</div>
<div style="background-color: #171717;">
<p style="padding-top: 1rem; padding-bottom: 1rem; text-align: center; font-size: 0.75rem; line-height: 1rem; color: #fff;">Copyright Peaceultime / d[any] - 2024 / 2025</p>
</div>
</template>

View File

@ -0,0 +1,18 @@
import { dom, text } from "#shared/dom.virtual.util";
export default function(data: any)
{
const hash = Bun.hash('1' + data.userId.toString(), data.timestamp);
return [dom('div', { style: 'max-width: 800px; margin-left: auto; margin-right: auto;' }, [
dom('p', { style: 'font-variant: small-caps; margin-bottom: 1rem; font-size: 1.25rem; line-height: 1.75rem;' }, [ text(`Bienvenue sur d[any], `), dom('span', { style: '' }, [ text(data.username) ]) ]),
dom('p', { style: '' }, [ text(`Nous vous invitons à valider votre compte afin de profiter de toutes les fonctionnalités de d[any].`) ]),
dom('div', { style: 'padding-top: 1rem; padding-bottom: 1rem; text-align: center;' }, [
dom('a', { attributes: { href: `https://d-any.com/user/mailvalidation?u=${data.userId}&i=${data.id}&t=${data.timestamp}&h=${hash}`, target: '_blank' } }, [ dom('span', { style: 'display: inline-block; border-width: 1px; border-color: #525252; background-color: #e5e5e5; padding-left: 0.75rem; padding-right: 0.75rem; padding-top: 0.25rem; padding-bottom: 0.25rem; font-weight: 200; color: #171717; text-decoration: none;' }, [ text('Je valide mon compte') ]) ]),
dom('span', { style: 'display: block; padding-top: 0.5rem; font-size: 0.75rem; line-height: 1rem;' }, [ text('Ce lien est valable 1 heure.') ])
]),
dom('div', { style: '' }, [
dom('span', { style: '' }, [ text('Vous pouvez egalement copier le lien suivant pour valider votre compte: ') ]),
dom('pre', { style: 'display: inline-block; border-bottom-width: 1px; font-size: 0.75rem; line-height: 1rem; color: #171717; font-weight: 400; text-decoration: none;' }, [ text(`https://d-any.com/user/mailvalidation?u=${data.userId}&i=${data.id}&t=${data.timestamp}&h=${hash}`) ])
])
])];
}

View File

@ -1,27 +0,0 @@
<template>
<div style="max-width: 800px; margin-left: auto; margin-right: auto;">
<p style="font-variant: small-caps; margin-bottom: 1rem; font-size: 1.25rem; line-height: 1.75rem;">Bienvenue sur d[any], <span>{{ username }}</span>.</p>
<p>Nous vous invitons à valider votre compte afin de profiter de toutes les fonctionnalités de d[any].</p>
<div style="padding-top: 1rem; padding-bottom: 1rem; text-align: center;">
<a :href="`https://d-any.com/user/mailvalidation?u=${userId}&i=${id}&t=${timestamp}&h=${hash}`" target="_blank"><span style="display: inline-block; border-width: 1px; border-color: #525252; background-color: #e5e5e5; padding-left: 0.75rem; padding-right: 0.75rem; padding-top: 0.25rem; padding-bottom: 0.25rem; font-weight: 200; color: #171717; text-decoration: none;">Je valide mon compte</span></a>
<span style="display: block; padding-top: 0.5rem; font-size: 0.75rem; line-height: 1rem;">Ce lien est valable 1 heure.</span>
</div>
<div>
<span>Vous pouvez egalement copier le lien suivant pour valider votre compte: </span>
<pre style="display: inline-block; border-bottom-width: 1px; font-size: 0.75rem; line-height: 1rem; color: #171717; font-weight: 400; text-decoration: none;">{{ `https://d-any.com/user/mailvalidation?u=${userId}&i=${id}&t=${timestamp}&h=${hash}` }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import Bun from 'bun';
const { id, userId, username, timestamp } = defineProps<{
id: number
userId: number
username: string
timestamp: number
}>();
const hash = computed(() => Bun.hash('1' + userId.toString(), timestamp));
</script>

View File

@ -0,0 +1,19 @@
import { dom, text } from "#shared/dom.virtual.util";
export default function(data: any)
{
const hash = Bun.hash('1' + data.userId.toString(), data.timestamp);
return [dom('div', { style: 'max-width: 800px; margin-left: auto; margin-right: auto;' }, [
dom('p', { style: 'font-variant: small-caps; margin-bottom: 1rem; font-size: 1.25rem; line-height: 1.75rem;' }, [ text(`Bonjour `), dom('span', { style: '' }, [ text(data.username) ]) ]),
dom('p', { style: '' }, [ text(`Vous avez demandé à réinitialiser votre mot de passe aujourd'hui à ${ format(new Date(data.timestamp), 'HH:mm') }.`) ]),
dom('div', { style: 'padding-top: 1rem; padding-bottom: 1rem; text-align: center;' }, [
dom('a', { attributes: { href: `https://d-any.com/user/resetting-password?u=${data.userId}&i=${data.id}&t=${data.timestamp}&h=${hash}`, target: '_blank' } }, [ dom('span', { style: 'display: inline-block; border-width: 1px; border-color: #525252; background-color: #e5e5e5; padding-left: 0.75rem; padding-right: 0.75rem; padding-top: 0.25rem; padding-bottom: 0.25rem; font-weight: 200; color: #171717; text-decoration: none;' }, [ text('Je change mon mot de passe.') ]) ]),
dom('span', { style: 'display: block; padding-top: 0.5rem; font-size: 0.75rem; line-height: 1rem;' }, [ text('Ce lien est valable 1 heure.') ])
]),
dom('div', { style: '' }, [
dom('span', { style: '' }, [ text('Vous pouvez egalement copier le lien suivant pour valider votre compte: ') ]),
dom('pre', { style: 'display: inline-block; border-bottom-width: 1px; font-size: 0.75rem; line-height: 1rem; color: #171717; font-weight: 400; text-decoration: none;' }, [ text(`https://d-any.com/user/mailvalidation?u=${data.userId}&i=${data.id}&t=${data.timestamp}&h=${hash}`) ])
]),
dom('span', { style: '', text: 'Si vous n\'êtes pas à l\'origine de cette demande, vous n\'avez pas à modifier votre mot de passe, ce dernier est conservé tant que vous n\'interagissez pas avec le lien ci-dessus.' }),
])];
}

View File

@ -1,29 +0,0 @@
<template>
<div style="max-width: 800px; margin-left: auto; margin-right: auto;">
<p style="font-variant: small-caps; margin-bottom: 1rem; font-size: 1.25rem; line-height: 1.75rem;">Bonjour <span>{{ username }}</span>.</p>
<p>Vous avez demandé à réinitialiser votre mot de passe aujourd'hui à {{ format(new Date(timestamp), 'HH:mm') }}.</p>
<div style="padding-top: 1rem; padding-bottom: 1rem; text-align: center;">
<a :href="`https://d-any.com/user/resetting-password?u=${userId}&i=${id}&t=${timestamp}&h=${hash}`" target="_blank"><span style="display: inline-block; border-width: 1px; border-color: #525252; background-color: #e5e5e5; padding-left: 0.75rem; padding-right: 0.75rem; padding-top: 0.25rem; padding-bottom: 0.25rem; font-weight: 200; color: #171717; text-decoration: none;">Je change mon mot de passe.</span></a>
<span style="display: block; padding-top: 0.5rem; font-size: 0.75rem; line-height: 1rem;">Ce lien est valable 1 heure.</span>
</div>
<div>
<span>Vous pouvez egalement copier le lien suivant pour changer votre mot de passe: </span>
<pre style="display: inline-block; border-bottom-width: 1px; font-size: 0.75rem; line-height: 1rem; color: #171717; font-weight: 400; text-decoration: none;">{{ `https://d-any.com/user/resetting-password?u=${userId}&i=${id}&t=${timestamp}&h=${hash}` }}</pre>
</div>
<span>Si vous n'êtes pas à l'origine de cette demande, vous n'avez pas à modifier votre mot de passe, ce dernier est conservé tant que vous n'interagissez pas avec le lien ci-dessus.</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import Bun from 'bun';
import { format } from '#shared/general.util';
const { id, userId, username, timestamp } = defineProps<{
id: number
userId: number
username: string
timestamp: number
}>();
const hash = computed(() => Bun.hash('2' + userId.toString(), timestamp));
</script>

View File

@ -2,16 +2,20 @@ import nodemailer from 'nodemailer';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import base from '../components/mail/base.vue';
import base from '../components/mail/base';
import registration from '../components/mail/registration';
import reset_password from '../components/mail/reset-password';
/* import base from '../components/mail/base.vue';
import Registration from '../components/mail/registration.vue';
import ResetPassword from '../components/mail/reset-password.vue';
import ResetPassword from '../components/mail/reset-password.vue'; */
const config = useRuntimeConfig();
const [domain, selector, dkim] = config.mail.dkim.split(":");
export const templates: Record<string, { component: any, subject: string }> = {
"registration": { component: Registration, subject: 'Bienvenue sur d[any] 😎' },
"reset-password": { component: ResetPassword, subject: 'Réinitialisation de votre mot de passe' },
export const templates: Record<string, { component: (data: any) => string[], subject: string }> = {
"registration": { component: registration, subject: 'Bienvenue sur d[any] 😎' },
"reset-password": { component: reset_password, subject: 'Réinitialisation de votre mot de passe' },
};
import type Mail from 'nodemailer/lib/mailer';
@ -76,11 +80,13 @@ export default defineTask({
throw new Error(`Modèle de mail ${mailPayload.template} inconnu`);
}
console.log(`<html><body>${base(template.component(mailPayload.data))}</body></html>`);
console.time('Generating HTML');
const mail: Mail.Options = {
from: 'd[any] - Ne pas répondre <no-reply@peaceultime.com>',
to: mailPayload.to,
html: await render(template.component, mailPayload.data),
html: `<html><body>${base(template.component(mailPayload.data))}</body></html>`,
subject: template.subject,
textEncoding: 'quoted-printable',
};
@ -107,16 +113,3 @@ export default defineTask({
}
}
})
async function render(component: any, data: Record<string, any>): Promise<string>
{
const app = createSSRApp({
render(){
return h(base, null, { default: () => h(component, data, { default: () => null }) });
}
});
const html = await renderToString(app);
return (`<html><body><div>${html}</div></body></html>`);
}

View File

@ -107,5 +107,5 @@ function _useSession(event: H3Event | CompatEvent) {
sessionConfig = runtimeConfig.session;
}
return useSession<UserSession>(event, sessionConfig);
return useSession<UserSession>(event, sessionConfig ?? {});
}

View File

@ -1,13 +1,13 @@
import { z } from "zod/v4";
import type { User } from "~/types/auth";
import type { Campaign } from "~/types/campaign";
import type { Campaign, CampaignLog } from "~/types/campaign";
import { div, dom, icon, span, svg, text } from "#shared/dom.util";
import { button, loading, tabgroup } from "#shared/components.util";
import { CharacterCompiler } from "#shared/character.util";
import { tooltip } from "#shared/floating.util";
import markdown from "#shared/markdown.util";
import { preview } from "./proses";
import { format } from "./general.util";
import { preview } from "#shared/proses";
import { format } from "#shared/general.util";
import { Socket } from "#shared/websocket.util";
export const CampaignValidation = z.object({
@ -49,6 +49,13 @@ type PlayerState = {
dom: HTMLElement;
user: { id: number, username: string };
};
const logType: Record<CampaignLog['type'], string> = {
CHARACTER: ' a rencontré ',
FIGHT: ' a affronté ',
ITEM: ' a obtenu ',
PLACE: ' est arrivé ',
TEXT: ' ',
}
const activity = {
online: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-green dark:bg-dark-green border-light-green dark:border-dark-green', text: 'En ligne' },
afk: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-yellow dark:bg-dark-yellow border-light-yellow dark:border-dark-yellow', text: 'Inactif' },
@ -128,6 +135,10 @@ export class CampaignSheet
]));
});
}
private logText(log: CampaignLog)
{
return `${log.target === 0 ? 'Le groupe' : this.players.find(e => e.user.id === log.target)?.user.username ?? 'Le groupe'}${logType[log.type]}${log.details}`;
}
private render()
{
const campaign = this.campaign;
@ -147,9 +158,9 @@ export class CampaignSheet
]),
div('flex flex-1 flex-col items-center justify-center', [
div('border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2', [
dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`https://d-any.com/campaign/join/${ encodeURIComponent(campaign.link) }`) ]),
dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`d-any.com/campaign/join/${ encodeURIComponent(campaign.link) }`) ]),
button(icon('radix-icons:clipboard', { width: 16, height: 16 }), () => {}, 'p-1'),
])
]),
]),
]),
div('flex flex-row gap-4 flex-1', [
@ -158,7 +169,7 @@ export class CampaignSheet
...this.characters.map(e => e.container),
]),
div('flex h-full border-l border-light-40 dark:border-dark-40'),
div('flex flex-col w-full max-w-[900px] w-[900px]', [
div('flex flex-col', [
tabgroup([
{ id: 'campaign', title: [ text('Campagne') ], content: () => [
markdown(campaign.public_notes, '', { tags: { a: preview } }),
@ -166,23 +177,43 @@ export class CampaignSheet
{ id: 'inventory', title: [ text('Inventaire') ], content: () => [
] },
{ id: 'logs', title: [ text('Logs') ], content: () => [
campaign.logs.length > 0 ? div('flex flex-row ps-12 py-4', [
div('border-l-2 border-light-40 dark:border-dark-40'),
div('flex flex-col gap-8 py-4', campaign.logs.map(e => div('flex flex-row gap-2 items-center relative -left-4', [
div('w-3 h-3 border-2 rounded-full bg-light-40 dark:border-dark-40 border-light-0 dark:border-dark-0'),
div('flex flex-row items-center', [ svg('svg', { class: ' fill-light-40 dark:fill-dark-40', attributes: { width: "6", height: "9", viewBox: "0 0 6 9" } }, [svg('path', { attributes: { d: "M0 4.5L6 -4.15L6 9L0 4.5Z" } })]), span('px-4 py-2 bg-light-25 dark:bg-dark-25 border border-light-40 dark:border-dark-40', e.details) ]),
span('italic text-sm tracking-tight text-light-70 dark:text-dark-70', format(new Date(e.timestamp), 'hh:mm:ss')),
])))
]) : div('flex py-4 px-16', [ span('italic text-light-70 dark:text-darl-70', 'Aucune entrée pour le moment') ])
] },
{ id: 'logs', title: [ text('Logs') ], content: () => {
let lastDate: Date = new Date(0);
const logs = campaign.logs.flatMap(e => {
const date = new Date(e.timestamp), arr = [];
if(Math.floor(lastDate.getTime() / 86400000) < Math.floor(date.getTime() / 86400000))
{
lastDate = date;
arr.push(div('flex flex-row gap-2 items-center relative -left-2 mx-px', [
div('w-3 h-3 border-2 rounded-full bg-light-40 dark:bg-dark-40 border-light-0 dark:border-dark-0'),
div('flex flex-row gap-2 items-center flex-1', [
div('flex-1 border-t border-light-40 dark:border-dark-40 border-dashed'),
span('text-light-70 dark:text-dark-70 text-sm italic', format(date, 'dd MMMM yyyy')),
div('flex-1 border-t border-light-40 dark:border-dark-40 border-dashed'),
])
]))
}
arr.push(div('flex flex-row gap-2 items-center relative -left-2 mx-px', [
div('w-3 h-3 border-2 rounded-full bg-light-40 dark:bg-dark-40 border-light-0 dark:border-dark-0'),
div('flex flex-row items-center', [ svg('svg', { class: 'fill-light-40 dark:fill-dark-40', attributes: { width: "6", height: "9", viewBox: "0 0 6 9" } }, [svg('path', { attributes: { d: "M0 4.5L6 -4.15L6 9L0 4.5Z" } })]), span('px-4 py-2 bg-light-25 dark:bg-dark-25 border border-light-40 dark:border-dark-40', this.logText(e)) ]),
span('italic text-xs tracking-tight text-light-70 dark:text-dark-70', format(new Date(e.timestamp), 'HH:mm:ss')),
]));
return arr;
});
return [
campaign.logs.length > 0 ? div('flex flex-row ps-12 py-4', [
div('border-l-2 border-light-40 dark:border-dark-40 relative before:absolute before:block before:border-[6px] before:border-b-[12px] before:-left-px before:-translate-x-1/2 before:border-transparent before:border-b-light-40 dark:before:border-b-dark-40 before:-top-3'),
div('flex flex-col-reverse gap-8 py-4', logs),
]) : div('flex py-4 px-16', [ span('italic text-light-70 dark:text-darl-70', 'Aucune entrée pour le moment') ]),
]
} },
{ id: 'settings', title: [ text('Paramètres') ], content: () => [
] },
{ id: 'ressources', title: [ text('Ressources') ], content: () => [
] }
], { focused: 'campaign', })
], { focused: 'campaign', class: { container: 'max-w-[900px] w-[900px]' } }),
])
]))
}

106
shared/dom.virtual.util.ts Normal file
View File

@ -0,0 +1,106 @@
import { iconLoaded, loadIcon, getIcon } from "iconify-icon";
import type { NodeProperties, Class } from "#shared/dom.util";
export type VirtualNode = string;
export function dom<K extends keyof HTMLElementTagNameMap>(tag: K, properties?: NodeProperties, children?: VirtualNode[]): VirtualNode
{
const node = [`<${tag}`];
if(properties?.attributes)
for(const [k, v] of Object.entries(properties.attributes))
if(typeof v === 'string' || typeof v === 'number') node.push(` ${k}="${v.toString(10).replaceAll('"', "'")}"`);
else if(typeof v === 'boolean' && v) node.push(` ${k.replaceAll('"', "'")}`);
if(properties?.class)
node.push(` class="${mergeClasses(properties.class).replaceAll('"', "'")}"`);
if(properties?.style)
{
if(typeof properties.style === 'string') node.push(` style="${properties.style.replaceAll('"', "'")}"`);
else node.push(` style="${Object.entries(properties.style).map(([k, v]) => { if(v !== undefined && v !== false) return `${k.replaceAll('"', "'")}: ${v.toString(10).replaceAll('"', "'")};` }).join('')}"`);
}
if(properties?.text)
{
children ??= [];
children?.push(properties.text);
}
if(children)
node.push(`>${children.filter(e => !!e).join('')}</${tag}>`);
else
node.push(`></${tag}>`)
return node.join('');
}
export function div(cls?: Class, children?: VirtualNode[]): VirtualNode
{
return dom("div", { class: cls }, children);
}
export function span(cls?: Class, text?: string): VirtualNode
{
return dom("span", { class: cls, text: text });
}
export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: VirtualNode[]): VirtualNode
{
const node = [`<${tag}`];
if(properties?.attributes)
for(const [k, v] of Object.entries(properties.attributes))
if(typeof v === 'string' || typeof v === 'number') node.push(` ${k.replaceAll('"', "'")}="${v.toString(10).replaceAll('"', "'")}"`);
else if(typeof v === 'boolean' && v) node.push(` ${k.replaceAll('"', "'")}`);
if(properties?.class)
node.push(` class="${mergeClasses(properties.class).replaceAll('"', "'")}"`);
if(properties?.style)
{
if(typeof properties.style === 'string') node.push(` style="${properties.style.replaceAll('"', "'")}"`);
else node.push(` style="${Object.entries(properties.style).map(([k, v]) => { if(v !== undefined && v !== false) return `${k.replaceAll('"', "'")}: ${v.toString(10).replaceAll('"', "'")};"` }).join('')}"`);
}
if(children)
node.push(`>${children.filter(e => !!e).join('')}</${tag}>`);
else
node.push(`></${tag}>`)
return node.join(' ');
}
export function text(data: string): VirtualNode
{
return data;
}
export interface IconProperties
{
mode?: string;
inline?: boolean;
noobserver?: boolean;
width?: string|number;
height?: string|number;
flip?: string;
rotate?: number|string;
style?: Record<string, string | undefined> | string;
class?: Class;
}
export function icon(name: string, properties?: IconProperties): VirtualNode
{
return '';
}
export function mergeClasses(classes: Class): string
{
if(typeof classes === 'string')
{
return classes.trim();
}
else if(Array.isArray(classes))
{
return classes.map(e => mergeClasses(e)).join(' ');
}
else if(classes)
{
return Object.entries(classes).filter(e => e[1]).map(e => e[0].trim()).join(' ');
}
else
{
return '';
}
}

View File

@ -75,8 +75,23 @@ export function padRight(text: string, pad: string, length: number): string
}
export function format(date: Date, template: string): string
{
const months = {
0: 'janvier',
1: 'fevrier',
2: 'mars',
3: 'avril',
4: 'mai',
5: 'juin',
6: 'juillet',
7: 'aout',
8: 'septembre',
9: 'octobre',
10: 'novembre',
11: 'decembre',
};
const transforms: Record<string, (date: Date) => string> = {
"yyyy": (date: Date) => date.getUTCFullYear().toString(),
"MMMM": (date: Date) => months[date.getUTCMonth() as keyof typeof months],
"MM": (date: Date) => padRight((date.getUTCMonth() + 1).toString(), '0', 2),
"dd": (date: Date) => padRight(date.getUTCDate().toString(), '0', 2),
"mm": (date: Date) => padRight(date.getUTCMinutes().toString(), '0', 2),