From c9f60d92ca21da70523ec2d424beaeecad8788ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pons?= Date: Wed, 19 Nov 2025 17:14:45 +0100 Subject: [PATCH] Fix Campaign log DB and rendering. Migrate mail rendering to virtual DOM API. --- app/db/schema.ts | 3 +- app/types/campaign.d.ts | 2 +- db.sqlite | Bin 761856 -> 761856 bytes drizzle/0021_familiar_dagger.sql | 2 +- drizzle/0022_warm_bushwacker.sql | 2 +- drizzle/0024_secret_arclight.sql | 2 +- drizzle/meta/0021_snapshot.json | 13 --- drizzle/meta/0022_snapshot.json | 13 --- drizzle/meta/0023_snapshot.json | 13 --- drizzle/meta/0024_snapshot.json | 13 --- nuxt.config.ts | 15 +-- server/components/mail/base.ts | 6 +- server/components/mail/base.vue | 16 ---- server/components/mail/registration.ts | 18 ++++ server/components/mail/registration.vue | 27 ------ server/components/mail/reset-password.ts | 19 ++++ server/components/mail/reset-password.vue | 29 ------ server/tasks/mail.ts | 33 +++---- server/utils/session.ts | 2 +- shared/campaign.util.ts | 65 +++++++++---- shared/dom.virtual.util.ts | 106 ++++++++++++++++++++++ shared/general.util.ts | 15 +++ 22 files changed, 237 insertions(+), 177 deletions(-) delete mode 100644 server/components/mail/base.vue create mode 100644 server/components/mail/registration.ts delete mode 100644 server/components/mail/registration.vue create mode 100644 server/components/mail/reset-password.ts delete mode 100644 server/components/mail/reset-password.vue create mode 100644 shared/dom.virtual.util.ts diff --git a/app/db/schema.ts b/app/db/schema.ts index e49a23d..eca872f 100644 --- a/app/db/schema.ts +++ b/app/db/schema.ts @@ -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], }), })); \ No newline at end of file diff --git a/app/types/campaign.d.ts b/app/types/campaign.d.ts index c8d1c8d..0646fc6 100644 --- a/app/types/campaign.d.ts +++ b/app/types/campaign.d.ts @@ -21,6 +21,6 @@ export type Campaign = { export type CampaignLog = { target: number; timestamp: Serialize; - type: 'ITEM' | 'CHARACTER' | 'PLACE' | 'EVENT' | 'FIGHT' | 'TEXT'; + type: 'ITEM' | 'CHARACTER' | 'PLACE' | 'FIGHT' | 'TEXT'; details: string; }; \ No newline at end of file diff --git a/db.sqlite b/db.sqlite index 1dd24cc33d5dbc3ba176a6ea074041ca6d276b1a..6176e21e8d69be8575fb9cb4e52de5c3d010805e 100644 GIT binary patch delta 1128 zcmd5*T};zZ6mGk9myKaZ2#ySpYaXT99yYY6SC{v$JbCUD(-SeG$Prt&Jx3J}1 zudfaf1n~sh4Qy6y7q5Gc(;>n;CqIfays7cs)y-+X2n}bc@VG#lADU|HeHu7MHBgp0j?^;<9$9@`icud3Y!U=Szid!8;urOK`3t0 zn#O3TpBkrKW^HGj?$?Urw6myB(z-rr8M3^~!|2S~X0^P?dGb*wro zBi5>-8P%9Bj#NRE6kPp~0SyDj@Sf7hxDR%Io^He7(Ag1gAUq6Db7QRrunmrIA#Q{l z?&Z!w_Q$`(mTKDPDd5)LC}5Bb>RuIG17?CC>$LS*;5xehwjI2$ER{#b4)5K{0k*%V z%i=uWP7z`%j%F}YR1q&^)pRN5_aF6_ck>gAMl-E24_uln2O28#c$(Wzx0_tKtc_;C z5h~E7eVqpZ+mZ*b3g?%dM!rcCa^SG{Pdoje9$F79fESBcb=w5$VAVZm+Nx-* g5pq{^ro9L&i?SOv>#lrO9Tlq%>#D{{R30 delta 612 zcmZoTpx1CfZ-TU-69WUoE+C!)#GF7JHc`iz)rmo`OJrjTe>}6y5{=De@$CvAMpMJK z21b?*%tAbM)7cG}R5v>+1o5=j*)RbyGZ3?Eud`vDCCgS^keZspJY7DLRbjhb6sr`A zL~2DwVrg+nW`3TMm4S{@MrvYCNrsY@;q*=MtnTgG<5;(Ek7M&)xSd6S=|BH=W&w8T z1P&ex?mxU&d9Q9)D`1yklE_bQRxo8}GiL+3k%6IE#FSlJU7fKtefsJec9rRtwd^vo z37IJg3YmE&sp+XjI$WFyX+`4ybv$ zeHlBuAXtZvLPAMqZfbE!Vs627|1$OrVX&~KLXfMQYmlp-vum(I!t{fs?7}<=8VSi6 ziA9OYC8K-^sn za<}|+lUjB~d31M|Bo?KomXPFfVW=Jqw~In0h;ltUFl19BjhUwJt7aFUo>RcCAu2l#`ka43)&p^gIZ?-KviLRO51%2aE#kECL)r%n8I?z=-7j(+>a~ C4!&yu diff --git a/drizzle/0021_familiar_dagger.sql b/drizzle/0021_familiar_dagger.sql index ef8dc09..01b6608 100644 --- a/drizzle/0021_familiar_dagger.sql +++ b/drizzle/0021_familiar_dagger.sql @@ -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 diff --git a/drizzle/0022_warm_bushwacker.sql b/drizzle/0022_warm_bushwacker.sql index 5ec54c0..f812ec6 100644 --- a/drizzle/0022_warm_bushwacker.sql +++ b/drizzle/0022_warm_bushwacker.sql @@ -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 diff --git a/drizzle/0024_secret_arclight.sql b/drizzle/0024_secret_arclight.sql index 7b4c301..2771077 100644 --- a/drizzle/0024_secret_arclight.sql +++ b/drizzle/0024_secret_arclight.sql @@ -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 diff --git a/drizzle/meta/0021_snapshot.json b/drizzle/meta/0021_snapshot.json index 42cea45..a0f3a9e 100644 --- a/drizzle/meta/0021_snapshot.json +++ b/drizzle/meta/0021_snapshot.json @@ -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": { diff --git a/drizzle/meta/0022_snapshot.json b/drizzle/meta/0022_snapshot.json index 7136b67..4f622cf 100644 --- a/drizzle/meta/0022_snapshot.json +++ b/drizzle/meta/0022_snapshot.json @@ -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": { diff --git a/drizzle/meta/0023_snapshot.json b/drizzle/meta/0023_snapshot.json index 1019587..4fcdbc3 100644 --- a/drizzle/meta/0023_snapshot.json +++ b/drizzle/meta/0023_snapshot.json @@ -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": { diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json index c54a84f..3a74785 100644 --- a/drizzle/meta/0024_snapshot.json +++ b/drizzle/meta/0024_snapshot.json @@ -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": { diff --git a/nuxt.config.ts b/nuxt.config.ts index d159b92..49fd360 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -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', diff --git a/server/components/mail/base.ts b/server/components/mail/base.ts index 0532ebf..29be60d 100644 --- a/server/components/mail/base.ts +++ b/server/components/mail/base.ts @@ -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]' }) ]) ]), diff --git a/server/components/mail/base.vue b/server/components/mail/base.vue deleted file mode 100644 index f0067a5..0000000 --- a/server/components/mail/base.vue +++ /dev/null @@ -1,16 +0,0 @@ - \ No newline at end of file diff --git a/server/components/mail/registration.ts b/server/components/mail/registration.ts new file mode 100644 index 0000000..544b08f --- /dev/null +++ b/server/components/mail/registration.ts @@ -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}`) ]) + ]) + ])]; +} \ No newline at end of file diff --git a/server/components/mail/registration.vue b/server/components/mail/registration.vue deleted file mode 100644 index 55f4ba0..0000000 --- a/server/components/mail/registration.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - \ No newline at end of file diff --git a/server/components/mail/reset-password.ts b/server/components/mail/reset-password.ts new file mode 100644 index 0000000..d1fe15a --- /dev/null +++ b/server/components/mail/reset-password.ts @@ -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.' }), + ])]; +} \ No newline at end of file diff --git a/server/components/mail/reset-password.vue b/server/components/mail/reset-password.vue deleted file mode 100644 index 96eba61..0000000 --- a/server/components/mail/reset-password.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - \ No newline at end of file diff --git a/server/tasks/mail.ts b/server/tasks/mail.ts index 754d661..81b7d0c 100644 --- a/server/tasks/mail.ts +++ b/server/tasks/mail.ts @@ -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 = { - "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[], 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(`${base(template.component(mailPayload.data))}`); + console.time('Generating HTML'); const mail: Mail.Options = { from: 'd[any] - Ne pas répondre ', to: mailPayload.to, - html: await render(template.component, mailPayload.data), + html: `${base(template.component(mailPayload.data))}`, subject: template.subject, textEncoding: 'quoted-printable', }; @@ -106,17 +112,4 @@ export default defineTask({ return { result: false, error: e }; } } -}) - -async function render(component: any, data: Record): Promise -{ - const app = createSSRApp({ - render(){ - return h(base, null, { default: () => h(component, data, { default: () => null }) }); - } - }); - - const html = await renderToString(app); - - return (`
${html}
`); -} \ No newline at end of file +}) \ No newline at end of file diff --git a/server/utils/session.ts b/server/utils/session.ts index e4d25a6..5eb44c1 100644 --- a/server/utils/session.ts +++ b/server/utils/session.ts @@ -107,5 +107,5 @@ function _useSession(event: H3Event | CompatEvent) { sessionConfig = runtimeConfig.session; } - return useSession(event, sessionConfig); + return useSession(event, sessionConfig ?? {}); } \ No newline at end of file diff --git a/shared/campaign.util.ts b/shared/campaign.util.ts index 9b86390..e2952f8 100644 --- a/shared/campaign.util.ts +++ b/shared/campaign.util.ts @@ -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 = { + 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]' } }), ]) ])) } diff --git a/shared/dom.virtual.util.ts b/shared/dom.virtual.util.ts new file mode 100644 index 0000000..4689678 --- /dev/null +++ b/shared/dom.virtual.util.ts @@ -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(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('')}`); + else + node.push(`>`) + + 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(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('')}`); + else + node.push(`>`) + + 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; + 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 ''; + } +} \ No newline at end of file diff --git a/shared/general.util.ts b/shared/general.util.ts index 313315f..bd069ce 100644 --- a/shared/general.util.ts +++ b/shared/general.util.ts @@ -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> = { "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),