19 Commits

Author SHA1 Message Date
Clément Pons
25bd165f1d Merge branch 'dev' into HEAD 2025-10-21 17:26:16 +02:00
Clément Pons
5c1f41b0b7 Fix ProseH remains, rollback layout rendering and add proper scrolling to the character sheet tabs 2025-10-21 17:22:46 +02:00
feb2fb56c6 New default layout without vuejs rendering (still needs some fixes) 2025-10-19 23:35:11 +02:00
Clément Pons
df9ae95890 Note tab in character sheet 2025-10-15 17:01:23 +02:00
Clément Pons
72843f2425 Fix registration email and add no character friendly messages 2025-10-15 14:58:59 +02:00
Clément Pons
443612cc58 Floater pinned true handler, SQL schema update to handle private/public notes on character, fix Canvas zoom debounce on move. 2025-10-15 14:34:12 +02:00
Clément Pons
a577e3ccfc Checkbox and item panel improvements 2025-10-14 17:57:34 +02:00
Clément Pons
48e767944a Progress on ItemEditor interface and rendering 2025-10-13 17:56:22 +02:00
d187957915 Start implementing ItemEditor 2025-10-13 13:19:50 +02:00
Clément Pons
16cc3ee438 Floater imrprovement with parametrable show and hide events, title and minimization. 2025-10-10 16:57:36 +02:00
Clément Pons
26aa0847d9 Fix comrpessing bug on null buffers, make pinned floaters resizable and optimize a few things here and there 2025-10-06 17:42:16 +02:00
b19d2d1b41 Updated legal stuff, added floating popup that can be pin and move. Fix character compiler modifier updates not dirtying all dependents. 2025-10-05 23:54:37 +02:00
Clément Pons
89c4476ffb Merge branch 'dev' of https://git.peaceultime.com/Peaceultime/obsidian-visualiser into dev 2025-10-01 17:59:30 +02:00
Clément Pons
3113d8b0f3 Feature choice UI rework, feature editor fixes, new character manage page UI with tabgroup and action config 2025-10-01 17:59:14 +02:00
2b39f26722 Merge branch 'dev' of https://git.peaceultime.com/peaceultime/obsidian-visualiser into dev 2025-09-30 21:50:59 +02:00
d2a807694b Fix compression error 2025-09-30 21:36:40 +02:00
Clément Pons
eb0c33deae New ability display, sereval Character compile and creation fixes 2025-09-30 18:03:38 +02:00
Clément Pons
61d2d144b7 Spell UI, variables saving and mail server fixes (finally working in prod !!!) 2025-09-30 17:15:49 +02:00
81f191d5f6 Compress middleware 2025-09-14 20:46:48 +02:00
53 changed files with 3885 additions and 1490 deletions

12
app.vue
View File

@@ -1,13 +1,11 @@
<template> <template>
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden"> <div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
<NuxtRouteAnnouncer/> <NuxtRouteAnnouncer/>
<TooltipProvider> <NuxtLayout>
<NuxtLayout> <div class="xl:px-12 xl:py-8 lg:px-8 lg:py-6 px-6 py-3 flex flex-1 justify-center overflow-auto max-h-full max-w-full relative" id="mainContainer">
<div class="xl:px-12 xl:py-8 lg:px-8 lg:py-6 px-6 py-3 flex flex-1 justify-center overflow-auto max-h-full max-w-full relative" id="mainContainer"> <NuxtPage />
<NuxtPage /> </div>
</div> </NuxtLayout>
</NuxtLayout>
</TooltipProvider>
</div> </div>
</template> </template>

View File

@@ -1,22 +0,0 @@
<template>
<span ref="container"></span>
</template>
<script setup lang="ts">
import { parseURL } from 'ufo';
import proses, { preview } from '#shared/proses';
import { text } from '#shared/dom.util';
const { href, label } = defineProps<{
href: string,
label: string
}>();
const container = useTemplateRef('container');
onMounted(() => {
queueMicrotask(() => {
container.value && container.value.appendChild(proses('a', preview, [ text(label) ], { href }) as HTMLElement);
});
});
</script>

View File

@@ -2,7 +2,9 @@ import { Database } from "bun:sqlite";
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite"; import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from '../db/schema'; import * as schema from '../db/schema';
let instance: BunSQLiteDatabase<typeof schema>; let instance: BunSQLiteDatabase<typeof schema> & {
$client: Database;
};
export default function useDatabase() export default function useDatabase()
{ {
if(!instance) if(!instance)
@@ -13,6 +15,7 @@ export default function useDatabase()
instance.run("PRAGMA journal_mode = WAL;"); instance.run("PRAGMA journal_mode = WAL;");
instance.run("PRAGMA foreign_keys = true;"); instance.run("PRAGMA foreign_keys = true;");
instance.run("PRAGMA optimize=0x10002;");
} }
return instance; return instance;

BIN
db.sqlite

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,5 @@
import { relations } from 'drizzle-orm'; import { relations } from 'drizzle-orm';
import { int, text, sqliteTable as table, primaryKey, blob } from 'drizzle-orm/sqlite-core'; import { int, text, sqliteTable as table, primaryKey, blob } from 'drizzle-orm/sqlite-core';
import { ABILITIES, MAIN_STATS } from '~/shared/character.util';
export const usersTable = table("users", { export const usersTable = table("users", {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
@@ -55,9 +54,10 @@ export const characterTable = table("character", {
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
people: text().notNull(), people: text().notNull(),
level: int().notNull().default(1), level: int().notNull().default(1),
variables: text({ mode: 'json' }).notNull().default('{"health": 0,"mana": 0,"spells": [],"equipment": [],"exhaustion": 0,"sickness": []}'), variables: text({ mode: 'json' }).notNull().default('{"health": 0,"mana": 0,"spells": [],"items": [],"exhaustion": 0,"sickness": [],"poisons": []}'),
aspect: int(), aspect: int(),
notes: text(), public_notes: text(),
private_notes: text(),
visibility: text({ enum: ['private', 'public'] }).notNull().default('private'), visibility: text({ enum: ['private', 'public'] }).notNull().default('private'),
thumbnail: blob(), thumbnail: blob(),

View File

@@ -0,0 +1,20 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_character` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`owner` integer NOT NULL,
`people` text NOT NULL,
`level` integer DEFAULT 1 NOT NULL,
`variables` text DEFAULT '{"health": 0,"mana": 0,"spells": [],"items": [],"exhaustion": 0,"sickness": [],"poisons": []}' NOT NULL,
`aspect` integer,
`public_notes` text,
`private_notes` text,
`visibility` text DEFAULT 'private' NOT NULL,
`thumbnail` blob,
FOREIGN KEY (`owner`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_character`("id", "name", "owner", "people", "level", "variables", "aspect", "public_notes", "private_notes", "visibility", "thumbnail") SELECT "id", "name", "owner", "people", "level", "variables", "aspect", "public_notes", "private_notes", "visibility", "thumbnail" FROM `character`;--> statement-breakpoint
DROP TABLE `character`;--> statement-breakpoint
ALTER TABLE `__new_character` RENAME TO `character`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,711 @@
{
"version": "6",
"dialect": "sqlite",
"id": "153969ef-bcdb-4bbd-bd57-01fbd8004fc6",
"prevId": "05b549e7-5b3f-40f4-9461-05db59391e20",
"tables": {
"character_abilities": {
"name": "character_abilities",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ability": {
"name": "ability",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"max": {
"name": "max",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"character_abilities_character_character_id_fk": {
"name": "character_abilities_character_character_id_fk",
"tableFrom": "character_abilities",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_abilities_character_ability_pk": {
"columns": [
"character",
"ability"
],
"name": "character_abilities_character_ability_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_choices": {
"name": "character_choices",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_choices_character_character_id_fk": {
"name": "character_choices_character_character_id_fk",
"tableFrom": "character_choices",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_choices_character_id_choice_pk": {
"columns": [
"character",
"id",
"choice"
],
"name": "character_choices_character_id_choice_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_leveling": {
"name": "character_leveling",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_leveling_character_character_id_fk": {
"name": "character_leveling_character_character_id_fk",
"tableFrom": "character_leveling",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_leveling_character_level_pk": {
"columns": [
"character",
"level"
],
"name": "character_leveling_character_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character": {
"name": "character",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"people": {
"name": "people",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"variables": {
"name": "variables",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"health\": 0,\"mana\": 0,\"spells\": [],\"items\": [],\"exhaustion\": 0,\"sickness\": [],\"poisons\": []}'"
},
"aspect": {
"name": "aspect",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"public_notes": {
"name": "public_notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"private_notes": {
"name": "private_notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'private'"
},
"thumbnail": {
"name": "thumbnail",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_owner_users_id_fk": {
"name": "character_owner_users_id_fk",
"tableFrom": "character",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"character_training": {
"name": "character_training",
"columns": {
"character": {
"name": "character",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stat": {
"name": "stat",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"level": {
"name": "level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"choice": {
"name": "choice",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"character_training_character_character_id_fk": {
"name": "character_training_character_character_id_fk",
"tableFrom": "character_training",
"tableTo": "character",
"columnsFrom": [
"character"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"character_training_character_stat_level_pk": {
"columns": [
"character",
"stat",
"level"
],
"name": "character_training_character_stat_level_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_validation": {
"name": "email_validation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_content": {
"name": "project_content",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_files": {
"name": "project_files",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner": {
"name": "owner",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"navigable": {
"name": "navigable",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"private": {
"name": "private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"project_files_path_unique": {
"name": "project_files_path_unique",
"columns": [
"path"
],
"isUnique": true
}
},
"foreignKeys": {
"project_files_owner_users_id_fk": {
"name": "project_files_owner_users_id_fk",
"tableFrom": "project_files",
"tableTo": "users",
"columnsFrom": [
"owner"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_permissions": {
"name": "user_permissions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_permissions_id_users_id_fk": {
"name": "user_permissions_id_users_id_fk",
"tableFrom": "user_permissions",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_permissions_id_permission_pk": {
"columns": [
"id",
"permission"
],
"name": "user_permissions_id_permission_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_sessions": {
"name": "user_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_sessions_user_id_users_id_fk": {
"name": "user_sessions_user_id_users_id_fk",
"tableFrom": "user_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {
"user_sessions_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "user_sessions_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users_data": {
"name": "users_data",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"signin": {
"name": "signin",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lastTimestamp": {
"name": "lastTimestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_data_id_users_id_fk": {
"name": "users_data_id_users_id_fk",
"tableFrom": "users_data",
"tableTo": "users",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hash": {
"name": "hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"users_hash_unique": {
"name": "users_hash_unique",
"columns": [
"hash"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"character\".\"notes\"": "\"character\".\"public_notes\""
}
},
"internal": {
"indexes": {}
}
}

View File

@@ -120,6 +120,13 @@
"when": 1756221197092, "when": 1756221197092,
"tag": "0016_wild_the_anarchist", "tag": "0016_wild_the_anarchist",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1760531331328,
"tag": "0017_workable_scrambler",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,87 +1,65 @@
<template> <template>
<CollapsibleRoot class="flex flex-1 flex-col" v-model:open="open"> <div class="flex flex-row w-full max-w-full h-full max-h-full" style="--sidebar-width: 300px">
<div class="z-30 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2"> <div class="bg-light-0 dark:bg-dark-0 w-[var(--sidebar-width)] border-r border-light-30 dark:border-dark-30 flex flex-col gap-2">
<div class="flex items-center px-2 gap-4"> <NuxtLink class="flex flex-row items-center justify-center group gap-2 my-2" aria-label="Accueil" :to="{ name: 'index', force: true }">
<CollapsibleTrigger asChild> <Avatar src="/logo.dark.svg" class="dark:block hidden" />
<Button icon class="!bg-transparent group md:hidden"> <Avatar src="/logo.light.svg" class="block dark:hidden" />
<Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" /> <span class="text-xl font-semibold group-hover:text-light-70 dark:group-hover:text-dark-70">d[any]</span>
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" /> </NuxtLink>
</Button> <div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden" ref="treeParent"></div>
</CollapsibleTrigger> <div class="flex flex-col my-4 items-center justify-center gap-1 text-xs text-light-60 dark:text-dark-60">
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-opacity-70 m-2 flex items-center gap-4" aria-label="Accueil" :to="{ path: '/', force: true }"> <NuxtLink class="hover:underline" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
<Avatar src="/logo.dark.svg" class="dark:block hidden" /> <NuxtLink class="hover:underline" :to="{ name: 'usage' }">Conditions d'utilisations</NuxtLink>
<Avatar src="/logo.light.svg" class="block dark:hidden" /> Copyright Peaceultime - 2025
<span class="text-xl max-md:hidden">d[any]</span>
</NuxtLink>
</div> </div>
<NavigationMenuRoot class="relative"> </div>
<NavigationMenuList class="flex items-center gap-8 max-md:hidden"> <div class="flex flex-col flex-1 h-full w-[calc(100vw-var(--sidebar-width))]">
<NavigationMenuItem> <div class="flex flex-row border-b border-light-30 dark:border-dark-30 justify-between px-8">
<NavigationMenuTrigger> <div class="flex flex-row gap-16 items-center">
<NuxtLink :href="{ name: 'character' }" class="text-light-70 dark:text-dark-70 border-b-[2px] border-transparent hover:border-accent-blue py-2 hover:!text-opacity-70 flex items-center" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Personnages</span><Icon icon="radix-icons:caret-down" /></NuxtLink> <NavigationMenuRoot class="relative">
</NavigationMenuTrigger> <NavigationMenuList class="flex items-center gap-8 max-md:hidden">
<NavigationMenuContent class="absolute top-0 w-full sm:w-auto bg-light-0 dark:bg-dark-0 border border-light-30 dark:border-dark-30 py-2 z-20 flex flex-col"> <NavigationMenuItem>
<NuxtLink :href="{ name: 'character-list' }" class="text-light-70 dark:text-dark-70 hover:bg-light-10 dark:hover:bg-dark-10 hover:text-light-100 dark:hover:text-dark-100 py-2 px-4" active-class="!text-accent-blue"><span class="flex-1 truncate">Personnages publics</span></NuxtLink> <NavigationMenuTrigger>
<NuxtLink :href="{ name: 'character-id-edit', params: { id: 'new' } }" class="text-light-70 dark:text-dark-70 hover:bg-light-10 dark:hover:bg-dark-10 hover:text-light-100 dark:hover:text-dark-100 py-2 px-4" active-class="!text-accent-blue"><span class="flex-1 truncate">Nouveau personnage</span></NuxtLink> <NuxtLink :href="{ name: 'character' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Personnages</span><Icon icon="radix-icons:caret-down" /></NuxtLink>
</NavigationMenuContent> </NavigationMenuTrigger>
</NavigationMenuItem> <NavigationMenuContent class="absolute top-0 w-full sm:w-auto bg-light-0 dark:bg-dark-0 border border-light-30 dark:border-dark-30 py-2 z-20 flex flex-col">
</NavigationMenuList> <NuxtLink :href="{ name: 'character-list' }" class="hover:bg-light-30 dark:hover:bg-dark-30 px-4 py-2 select-none" active-class="!text-accent-blue"><span class="flex-1 truncate">Personnages publics</span></NuxtLink>
<div class="absolute top-full left-0 flex w-full justify-center"> <NuxtLink :href="{ name: 'character-id-edit', params: { id: 'new' } }" class="hover:bg-light-30 dark:hover:bg-dark-30 px-4 py-2 select-none" active-class="!text-accent-blue"><span class="flex-1 truncate">Nouveau personnage</span></NuxtLink>
<NavigationMenuViewport class="h-[var(--radix-navigation-menu-viewport-height)] w-full origin-[top_center] flex justify-center overflow-hidden sm:w-[var(--radix-navigation-menu-viewport-width)]" /> </NavigationMenuContent>
</div> </NavigationMenuItem>
</NavigationMenuRoot> </NavigationMenuList>
<div class="flex items-center px-2 gap-4"> <div class="absolute top-full left-0 flex w-full justify-center">
<template v-if="!loggedIn"> <NavigationMenuViewport class="h-[var(--radix-navigation-menu-viewport-height)] w-full origin-[top_center] flex justify-center overflow-hidden sm:w-[var(--radix-navigation-menu-viewport-width)]" />
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">Se connecter</NuxtLink>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70 max-md:hidden" :to="{ name: 'user-register' }">Créer un compte</NuxtLink>
</template>
<template v-else>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
</template>
</div>
</div>
<div class="flex flex-1 flex-row relative h-screen w-screen overflow-hidden">
<!-- <CollapsibleContent asChild forceMount> -->
<div class="bg-light-0 dark:bg-dark-0 z-40 w-screen md:w-[18rem] border-r border-light-30 dark:border-dark-30 flex flex-col justify-between my-2 max-md:data-[state=closed]:hidden">
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden" ref="treeParent">
<div v-if="user" class="flex flex-1 py-4 px-2 flex-row flex-1 justify-between items-center">
<NuxtLink v-if="hasPermissions(user.permissions, ['admin', 'editor'])" :to="{ name: 'explore-edit' }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink>
</div> </div>
</div> </NavigationMenuRoot>
<div class="xl:px-12 px-6 pt-4 pb-2 text-center text-xs text-light-60 dark:text-dark-60"> <NuxtLink :href="{ name: 'character' }" class="flex flex-row gap-2 items-center border-b-2 border-transparent hover:border-accent-blue py-4 select-none" active-class="!text-accent-blue"><span class="px-3 flex-1 truncate">Campagnes</span></NuxtLink>
<NuxtLink class="hover:underline italic" :to="{ name: 'roadmap' }">Roadmap</NuxtLink> - <NuxtLink class="hover:underline italic" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
<p>Copyright Peaceultime - 2025</p>
</div>
</div> </div>
<!-- </CollapsibleContent> --> <div class="flex flex-row gap-16 items-center">
<template v-if="!loggedIn">
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">Se connecter</NuxtLink>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70 max-md:hidden" :to="{ name: 'user-register' }">Créer un compte</NuxtLink>
</template>
<template v-else>
<NuxtLink class="text-light-100 dark:text-dark-100 hover:text-light-70 dark:hover:text-dark-70" :to="{ name: 'user-login' }">{{ user!.username }}</NuxtLink>
</template>
</div>
</div>
<slot></slot> <slot></slot>
</div> </div>
</CollapsibleRoot> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js'; import { Icon } from '@iconify/vue/dist/iconify.js';
import type { DropdownOption } from '~/components/base/DropdownMenu.vue';
import { hasPermissions } from '#shared/auth.util';
import { TreeDOM } from '#shared/tree'; import { TreeDOM } from '#shared/tree';
import { Content, iconByType } from '#shared/content.util'; import { Content, iconByType } from '#shared/content.util';
import { dom, icon, text } from '#shared/dom.util'; import { dom, icon } from '#shared/dom.util';
import { unifySlug } from '#shared/general.util'; import { unifySlug } from '#shared/general.util';
import { popper, tooltip } from '#shared/floating.util'; import { tooltip } from '#shared/floating.util';
import { link } from '#shared/components.util'; import { link } from '#shared/components.util';
const options = ref<DropdownOption[]>([{
type: 'item',
label: 'Mon profil',
select: () => useRouter().push({ name: 'user-profile' }),
}, {
type: 'item',
label: 'Deconnexion',
select: () => clear(),
}]);
const open = ref(false); const open = ref(false);
const { loggedIn, user, clear } = useUserSession(); const { loggedIn, user } = useUserSession();
const { fetch } = useContent(); const { fetch } = useContent();
await fetch(false); await fetch(false);
@@ -97,11 +75,11 @@ const tree = new TreeDOM((item, depth) => {
item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined, item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined,
])]); ])]);
}, (item, depth) => { }, (item, depth) => {
return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [link({ class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, active: 'text-accent-blue' }, item.path ? { name: 'explore-path', params: { path: item.path } } : undefined, [ return dom('div', { class: 'group flex items-center ps-2 outline-none relative cursor-pointer', style: { 'padding-inline-start': `${depth / 1.5}em` } }, [link([
icon(iconByType[item.type], { class: 'w-5 h-5', width: 20, height: 20 }), icon(iconByType[item.type], { class: 'w-5 h-5', width: 20, height: 20 }),
dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }), dom('div', { class: 'pl-1.5 py-1.5 flex-1 truncate', text: item.title, attributes: { title: item.title } }),
item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined, item.private ? tooltip(icon('radix-icons:lock-closed', { class: 'mx-1' }), 'Privé', 'right') : undefined,
])]); ], { class: ['flex flex-1 items-center hover:border-accent-blue hover:text-accent-purple max-w-full'], attributes: { 'data-private': item.private }, active: 'text-accent-blue' }, item.path ? { name: 'explore-path', params: { path: item.path } } : undefined )]);
}, (item) => item.navigable); }, (item) => item.navigable);
(path.value?.split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree.toggle(tree.tree.search('path', e)[0], true)); (path.value?.split('/').map((e, i, a) => a.slice(0, i).join('/')) ?? []).forEach(e => tree.toggle(tree.tree.search('path', e)[0], true));
const treeParent = useTemplateRef('treeParent'); const treeParent = useTemplateRef('treeParent');
@@ -119,9 +97,7 @@ watch(route, () => {
onMounted(() => { onMounted(() => {
if(treeParent.value) if(treeParent.value)
{
treeParent.value.appendChild(tree.container); treeParent.value.appendChild(tree.container);
}
}) })
onUnmounted(() => { onUnmounted(() => {
unmount(); unmount();

View File

@@ -164,8 +164,8 @@ async function logout(user: User)
</Head> </Head>
<div class="flex flex-1 flex-col p-4"> <div class="flex flex-1 flex-col p-4">
<div class="flex flex-row justify-between items-center"> <div class="flex flex-row justify-between items-center">
<ProseH2 class="text-center flex-1">Administration</ProseH2> <h2 class="text-center flex-1 text-2xl font-bold">Administration</h2>
<Button><NuxtLink :to="{ name: 'admin-jobs' }">Jobs</NuxtLink></Button> <NuxtLink :to="{ name: 'admin-jobs' }"><Button>Jobs</Button></NuxtLink>
</div> </div>
<div class="flex flex-1 w-full justify-center items-stretch flex-row gap-4"> <div class="flex flex-1 w-full justify-center items-stretch flex-row gap-4">
<div class="flex-1"> <div class="flex-1">

View File

@@ -71,7 +71,7 @@ async function fetch()
<div class="flex flex-col justify-start items-center p-4"> <div class="flex flex-col justify-start items-center p-4">
<div class="flex flex-row justify-between items-center gap-8"> <div class="flex flex-row justify-between items-center gap-8">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span> <span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<ProseH2 class="text-center flex-1">Administration</ProseH2> <h2 class="text-center flex-1 text-2xl font-bold">Administration</h2>
</div> </div>
<div class="flex flex-row w-full gap-8"> <div class="flex flex-row w-full gap-8">
<Select label="Job" v-model="job"> <Select label="Job" v-model="job">

View File

@@ -1,17 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import characterConfig from '#shared/character-config.json'; import characterConfig from '#shared/character-config.json';
import { Icon } from '@iconify/vue/dist/iconify.js'; import { unifySlug } from '#shared/general.util';
import PreviewA from '~/components/prose/PreviewA.vue';
import { clamp, unifySlug } from '#shared/general.util';
import type { CompiledCharacter, SpellConfig } from '~/types/character';
import type { CharacterConfig } from '~/types/character'; import type { CharacterConfig } from '~/types/character';
import { abilityTexts, CharacterCompiler, CharacterSheet, defaultCharacter, elementTexts, spellTypeTexts } from '#shared/character.util'; import { CharacterSheet } from '#shared/character.util';
import { getText } from '#shared/i18n';
import { preview } from '#shared/proses';
import { div, dom, icon, text } from '#shared/dom.util';
import markdown from '#shared/markdown.util';
import { button, foldable } from '#shared/components.util';
import { fullblocker, tooltip } from '~/shared/floating.util';
/* /*
text-light-red dark:text-dark-red border-light-red dark:border-dark-red bg-light-red dark:bg-dark-red text-light-red dark:text-dark-red border-light-red dark:border-dark-red bg-light-red dark:bg-dark-red
text-light-blue dark:text-dark-blue border-light-blue dark:border-dark-blue bg-light-blue dark:bg-dark-blue text-light-blue dark:text-dark-blue border-light-blue dark:border-dark-blue bg-light-blue dark:bg-dark-blue
@@ -35,180 +26,12 @@ onMounted(() => {
if(container.value && id) if(container.value && id)
{ {
const character = new CharacterSheet(id, user); const character = new CharacterSheet(id, user);
container.value.appendChild(character.container); container.value.replaceWith(character.container);
} }
}); });
}); });
</script> </script>
<template> <template>
<div ref="container"></div> <div class="flex flex-1 w-full h-full items-start justify-center" ref="container"></div>
<!-- <div v-if="status === 'pending'">
<Head>
<Title>d[any] - Chargement ...</Title>
</Head>
</div>
<div v-else-if="status === 'success' && character && !error">
<Head>
<Title>d[any] - {{ character.name }}</Title>
</Head>
<div class="flex flex-row gap-4 justify-between">
<div></div>
<div class="flex lg:flex-row flex-col gap-6 items-center justify-center">
<div class="flex gap-6 items-center">
<Avatar src="" icon="radix-icons:person" size="large" />
<div class="flex flex-col">
<span class="text-xl font-bold">{{ character.name }}</span>
<span class="text-sm">De {{ character.username }}</span>
</div>
<div class="flex flex-col">
<span class="font-bold">Niveau {{ character.level }}</span>
<span>{{ config.peoples[character.race]?.name ?? 'Peuple inconnu' }}</span>
</div>
</div>
<div class="flex flex-row lg:border-l border-light-30 dark:border-dark-30 py-4 ps-4 gap-8">
<span class="flex flex-row items-center gap-2 text-3xl font-light">PV: <span class="font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35">{{ character.health - character.variables.health }}</span>/ {{ character.health }}</span>
<span class="flex flex-row items-center gap-2 text-3xl font-light">Mana: <span class="font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35">{{ character.mana - character.variables.mana }}</span>/ {{ character.mana }}</span>
</div>
</div>
<div class="self-center">
<Tooltip side="right" message="Modifier" v-if="user && user.id === character.owner"><NuxtLink :to="{ name: 'character-id-edit', params: { id: character.id } }"><Button icon><Icon icon="radix-icons:pencil-2" /></Button></NuxtLink></Tooltip>
</div>
</div>
<div class="flex flex-1 flex-col justify-center gap-4 *:py-2">
<div class="flex flex-row gap-4 items-center border-b border-light-30 dark:border-dark-30 me-4 pe-4 divide-x divide-light-30 dark:divide-dark-30">
<div class="flex relative justify-between ps-4 gap-2">
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.strength }}</span><span class="text-sm 2xl:text-base">Force</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.dexterity }}</span><span class="text-sm 2xl:text-base">Dextérité</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.constitution }}</span><span class="text-sm 2xl:text-base">Constitution</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.intelligence }}</span><span class="text-sm 2xl:text-base">Intelligence</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.curiosity }}</span><span class="text-sm 2xl:text-base">Curiosité</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.charisma }}</span><span class="text-sm 2xl:text-base">Charisme</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">+{{ character.modifier.psyche }}</span><span class="text-sm 2xl:text-base">Psyché</span></div>
</div>
<div class="flex flex-1 relative ps-4 flex-row items-center justify-between">
<div class="flex flex-col px-2 items-center"><span class="text-2xl font-bold">+{{ character.initiative }}</span><span>Initiative</span></div>
<div class="flex flex-col px-2 items-center"><span class="text-2xl font-bold">{{ character.speed === false ? "Aucun déplacement" : `${character.speed} cases` }}</span><span>Course</span></div>
</div>
<div class="flex flex-1 relative ps-4 flex-row items-center justify-between">
<Icon icon="ph:shield-checkered" class="w-8 h-8" />
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">{{ clamp(character.defense.static + character.defense.passivedodge + character.defense.passiveparry, 0, character.defense.hardcap) }}</span><span class="text-sm 2xl:text-base">Passive</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">{{ clamp(character.defense.static + character.defense.passivedodge + character.defense.activeparry, 0, character.defense.hardcap) }}</span><span class="text-sm 2xl:text-base">Blocage</span></div>
<div class="flex flex-col items-center"><span class="2xl:text-2xl text-xl font-bold">{{ clamp(character.defense.static + character.defense.activedodge + character.defense.passiveparry, 0, character.defense.hardcap) }}</span><span class="text-sm 2xl:text-base">Esquive</span></div>
</div>
</div>
<div class="flex flex-1 px-8">
<div class="flex flex-col pe-8 gap-4 py-2 w-80 border-r border-light-30 dark:border-dark-30">
<div class="flex flex-col">
<span class="text-lg font-semibold border-b border-light-30 dark:border-dark-30 mb-2">Compétences</span>
<div class="grid grid-cols-3 gap-1">
<div class="flex flex-col px-2 items-center text-sm text-light-70 dark:text-dark-70" v-for="(value, ability) of character.abilities"><span class="font-bold text-base text-light-100 dark:text-dark-100">+{{ value }}</span><span>{{ abilityTexts[ability] }}</span></div>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-lg font-semibold border-b border-light-30 dark:border-dark-30">Maitrises</span>
<div class="grid grid-cols-2 gap-x-3 gap-y-1 text-sm">
<PreviewA v-if="character.mastery.strength + character.mastery.dexterity > 0" href="regles/annexes/equipement#Les armes légères" label="Arme légère" />
<PreviewA v-if="character.mastery.strength + character.mastery.dexterity > 0" href="regles/annexes/equipement#Les armes de jet" label="Arme de jet" />
<PreviewA v-if="character.mastery.strength + character.mastery.dexterity > 0" href="regles/annexes/equipement#Les armes naturelles" label="Arme naturelle" />
<PreviewA v-if="character.mastery.strength > 1" href="regles/annexes/equipement#Les armes" label="Arme standard" />
<PreviewA v-if="character.mastery.strength > 1" href="regles/annexes/equipement#Les armes improvisées" label="Arme improvisée" />
<PreviewA v-if="character.mastery.strength > 2" href="regles/annexes/equipement#Les armes lourdes" label="Arme lourde" />
<PreviewA v-if="character.mastery.strength > 3" href="regles/annexes/equipement#Les armes à deux mains" label="Arme à deux mains" />
<PreviewA v-if="character.mastery.dexterity > 0 && character.mastery.strength > 1" href="regles/annexes/equipement#Les armes maniables" label="Arme maniable" />
<PreviewA v-if="character.mastery.dexterity > 1 && character.mastery.strength > 1" href="regles/annexes/equipement#Les armes à projectiles" label="Arme à projectiles" />
<PreviewA v-if="character.mastery.dexterity > 1 && character.mastery.strength > 2" href="regles/annexes/equipement#Les armes longues" label="Arme longue" />
<PreviewA v-if="character.mastery.shield > 0" href="regles/annexes/equipement#Les boucliers" label="Bouclier" />
<PreviewA v-if="character.mastery.shield > 0 && character.mastery.strength > 3" href="regles/annexes/equipement#Les boucliers à deux mains" label="Bouclier à deux mains" />
</div>
<div v-if="character.mastery.armor > 0" class="grid grid-cols-2 gap-x-3 gap-y-1 text-sm">
<PreviewA v-if="character.mastery.armor > 0" href="regles/annexes/equipement#Les armures légères" label="Armure légère" />
<PreviewA v-if="character.mastery.armor > 1" href="regles/annexes/equipement#Les armures" label="Armure standard" />
<PreviewA v-if="character.mastery.armor > 2" href="regles/annexes/equipement#Les armures lourdes" label="Armure lourde" />
</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1 text-sm">
<span>Précision: <span class="font-bold">{{ character.spellranks.precision }}</span></span>
<span>Savoir: <span class="font-bold">{{ character.spellranks.knowledge }}</span></span>
<span>Instinct: <span class="font-bold">{{ character.spellranks.instinct }}</span></span>
<span>Oeuvres: <span class="font-bold">{{ character.spellranks.arts }}</span></span>
</div>
</div>
</div>
<TabsRoot default-value="features" class="w-[60rem] max-h-full">
<TabsList class="flex flex-row relative px-4 gap-4">
<TabsIndicator class="absolute left-0 h-[3px] bottom-0 w-[--radix-tabs-indicator-size] translate-x-[--radix-tabs-indicator-position] transition-[width,transform] duration-300 bg-accent-blue"></TabsIndicator>
<TabsTrigger value="features" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Aptitudes</TabsTrigger>
<TabsTrigger value="spells" class="px-2 py-1 border-b border-transparent hover:border-accent-blue" v-if="character.spellslots > 0">Sorts</TabsTrigger>
<TabsTrigger value="inventory" class="px-2 py-1 border-b border-transparent hover:border-accent-blue" v-if="character.capacity !== false">Inventaire</TabsTrigger>
<TabsTrigger value="notes" class="px-2 py-1 border-b border-transparent hover:border-accent-blue">Notes</TabsTrigger>
</TabsList>
<TabsContent value="features" class="overflow-y-auto max-h-full">
<div class="flex flex-1 flex-col ps-8 gap-4 py-4">
<div class="grid grid-cols-3 gap-4">
<div class="flex flex-col col-span-2">
<span class="text-lg font-semibold">Actions</span>
<span class="text-sm text-light-70 dark:text-dark-70">Attaquer - Saisir - Faire chuter - Déplacer - Courir - Pas de coté - Lancer un sort - S'interposer - Se transformer - Utiliser un objet - Anticiper une action - Improviser</span>
<MarkdownRenderer :content="character.lists.action?.map(e => getText(e))?.join('\n')" :properties="{ tags: { a: preview } }" />
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col">
<span class="text-lg font-semibold">Réactions</span>
<span class="text-sm text-light-70 dark:text-dark-70">Parade - Esquive - Saisir une opportunité - Prendre en tenaille - Intercepter - Désarmer</span>
<MarkdownRenderer :content="character.lists.reaction?.map(e => getText(e))?.join('\n')" :properties="{ tags: { a: preview } }" />
</div>
<div class="flex flex-col">
<span class="text-lg font-semibold">Actions libre</span>
<span class="text-sm text-light-70 dark:text-dark-70">Analyser une situation - Communiquer</span>
<MarkdownRenderer :content="character.lists.freeaction?.map(e => getText(e))?.join('\n')" :properties="{ tags: { a: preview } }" />
</div>
</div>
</div>
<div class="flex flex-col">
<span class="text-lg font-semibold">Aptitudes</span>
<MarkdownRenderer :content="character.lists.passive?.map(e => getText(e))?.map(e => `> ${e}`).join('\n\n')" :properties="{ tags: { a: preview } }" />
</div>
</div>
</TabsContent>
<TabsContent v-if="character.spellslots > 0" value="spells" class="overflow-y-auto max-h-full">
<div class="flex flex-1 flex-col ps-8 gap-4 py-2">
<div class="flex flex-1 justify-between items-baseline px-2"><div></div><div class="flex gap-4 items-baseline"><span class="italic text-light-70 dark:text-dark-70 text-sm">{{ character.variables.spells.length }} / {{ character.spellslots }} sorts maitrisés</span><Button class="!font-normal" @click="openSpellPanel">Modifier</Button></div></div>
<div class="flex flex-col" v-if="[...(character.lists.spells ?? []), ...character.variables.spells].length > 0">
<div class="pb-4 px-2 mt-4 border-b last:border-none border-light-30 dark:border-dark-30 flex flex-col" v-for="spell of [...(character.lists.spells ?? []), ...character.variables.spells].map(e => config.spells.find((f: SpellConfig) => f.id === e)).filter(e => !!e)">
<div class="flex flex-row justify-between">
<span class="text-lg font-bold">{{ spell.name }}</span>
<div class="flex flex-row items-center gap-6">
<div class="flex flex-row text-sm gap-2">
<span v-for="element of spell.elements" :class="elementTexts[element].class" class="border !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px">{{ elementTexts[element].text }}</span>
</div>
<div class="flex flex-row text-sm gap-1">
<span class="" v-if="spell.rank !== 4">Rang {{ spell.rank }}</span><span v-if="spell.rank !== 4">/</span>
<span class="" v-if="spell.rank !== 4">{{ spellTypeTexts[spell.type] }}</span><span v-if="spell.rank !== 4">/</span>
<span class="">{{ spell.cost }} mana</span><span>/</span>
<span class="capitalize">{{ typeof spell.speed === 'string' ? spell.speed : `${spell.speed} minutes` }}</span>
</div>
</div>
</div>
<MarkdownRenderer :content="spell.effect" />
</div>
</div>
</div>
</TabsContent>
<TabsContent value="inventory" v-if="character.capacity !== false" class="overflow-y-auto max-h-full">
</TabsContent>
<TabsContent value="notes" class="overflow-y-auto max-h-full">
<div class="flex flex-1 flex-col ps-8 gap-4 py-8">
<MarkdownRenderer :content="character.notes" />
</div>
</TabsContent>
</TabsRoot>
</div>
</div>
</div>
<div v-else>
<Head>
<Title>d[any] - Erreur</Title>
</Head>
<div>Erreur de chargement</div>
</div> -->
</template> </template>

View File

@@ -35,46 +35,59 @@ async function duplicateCharacter(id: number)
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center"> <div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
<Loading size="large" /> <Loading size="large" />
</div> </div>
<div v-else-if="status === 'success'" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full"> <template v-else-if="status === 'success'">
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters"> <div v-if="characters && characters.length > 0" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
<NuxtLink :to="{ name: 'character-id', params: { id: character.id } }" class="group bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2"> <div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters">
<div class="flex flex-row gap-8 ps-4 items-center"> <NuxtLink :to="{ name: 'character-id', params: { id: character.id } }" class="group bg-light-10 dark:bg-dark-10 border-b border-light-35 dark:border-dark-35 p-2 flex flex-col gap-2">
<div class="flex flex-1 flex-col gap-2 justify-center"> <div class="flex flex-row gap-8 ps-4 items-center">
<span class="text-lg font-bold group-hover:text-accent-blue">{{ character.name }}</span> <div class="flex flex-1 flex-col gap-2 justify-center">
<span class="border-b w-full border-light-50 dark:border-dark-50"></span> <span class="text-lg font-bold group-hover:text-accent-blue">{{ character.name }}</span>
<div class="flex flex-row flex-1 items-stretch gap-4"> <span class="border-b w-full border-light-50 dark:border-dark-50"></span>
<span class="text-sm">Niveau {{ character.level }}</span> <div class="flex flex-row flex-1 items-stretch gap-4">
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span> <span class="text-sm">Niveau {{ character.level }}</span>
<span class="text-sm italic">{{ config.peoples[character.people!]?.name }}</span> <span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
</div> <span class="text-sm italic">{{ config.peoples[character.people!]?.name }}</span>
</div>
<div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
</div>
</NuxtLink>
<div class="flex justify-around items-center py-2 px-4 gap-4">
<NuxtLink :to="{ name: 'character-id-edit', params: { id: character.id } }" class="text-sm font-bold cursor-pointer hover:text-accent-blue">Editer</NuxtLink>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<NuxtLink @click="duplicateCharacter(character.id)" class="text-sm font-bold cursor-pointer hover:text-accent-blue">Dupliquer</NuxtLink>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<AlertDialogRoot>
<AlertDialogTrigger>
<span class="text-sm font-bold text-light-red dark:text-dark-red">Supprimer</span>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
<AlertDialogContent
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
<AlertDialogTitle class="text-3xl font-light relative -top-2">Supprimer {{ character.name }} ?</AlertDialogTitle>
<div class="flex flex-1 justify-end gap-4">
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
<AlertDialogAction asChild><Button @click="() => deleteCharacter(character.id)" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Oui</Button></AlertDialogAction>
</div> </div>
</AlertDialogContent> </div>
</AlertDialogPortal> <div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
</AlertDialogRoot> </div>
</NuxtLink>
<div class="flex justify-around items-center py-2 px-4 gap-4">
<NuxtLink :to="{ name: 'character-id-edit', params: { id: character.id } }" class="text-sm font-bold cursor-pointer hover:text-accent-blue">Editer</NuxtLink>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<NuxtLink @click="duplicateCharacter(character.id)" class="text-sm font-bold cursor-pointer hover:text-accent-blue">Dupliquer</NuxtLink>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<AlertDialogRoot>
<AlertDialogTrigger>
<span class="text-sm font-bold text-light-red dark:text-dark-red">Supprimer</span>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="bg-light-0 dark:bg-dark-0 opacity-70 fixed inset-0 z-40" />
<AlertDialogContent
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] max-h-[85vh] w-[90vw] max-w-[800px] translate-x-[-50%] translate-y-[-50%] bg-light-10 dark:bg-dark-10 border border-light-30 dark:border-dark-30 p-6 z-50 text-light-100 dark:text-dark-100">
<AlertDialogTitle class="text-3xl font-light relative -top-2">Supprimer {{ character.name }} ?</AlertDialogTitle>
<div class="flex flex-1 justify-end gap-4">
<AlertDialogCancel asChild><Button>Non</Button></AlertDialogCancel>
<AlertDialogAction asChild><Button @click="() => deleteCharacter(character.id)" class="border-light-red dark:border-dark-red hover:border-light-red dark:hover:border-dark-red hover:bg-light-redBack dark:hover:bg-dark-redBack text-light-red dark:text-dark-red focus:shadow-light-red dark:focus:shadow-dark-red">Oui</Button></AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</div> </div>
</div> </div>
</div> <div v-else class="flex flex-col gap-2 items-center flex-1">
<span class="text-lg font-bold">Vous n'avez pas encore de personnage</span>
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink>
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" :to="{ name: 'character-list' }">Qu'ont fait les autres ?</NuxtLink>
</div>
</template>
<div v-else> <div v-else>
<span>Erreur de chargement</span> <span>Erreur de chargement</span>
<span>{{ error?.message }}</span> <span>{{ error?.message }}</span>

View File

@@ -1,5 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import characterConfig from '#shared/character-config.json';
import type { CharacterConfig } from '~/types/character';
const { data: characters, error, status } = await useFetch(`/api/character`, { params: { visibility: "public" } }); const { data: characters, error, status } = await useFetch(`/api/character`, { params: { visibility: "public" } });
const config = characterConfig as CharacterConfig;
</script> </script>
<template> <template>
@@ -10,15 +13,34 @@ const { data: characters, error, status } = await useFetch(`/api/character`, { p
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center"> <div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
<Loading size="large" /> <Loading size="large" />
</div> </div>
<div v-else-if="status === 'success'" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full"> <template v-else-if="status === 'success'">
<div class="border border-light-30 dark:border-dark-30 p-3 flex flex-row gap-4" v-for="character of characters"> <div v-if="characters && characters.length > 0" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
<Avatar size="large" icon="radix-icons:person" src="" /> <div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters">
<div class="flex flex-1 flex-shrink flex-col truncate"> <NuxtLink :to="{ name: 'character-id', params: { id: character.id } }" class="group bg-light-10 dark:bg-dark-10 p-2 flex flex-col gap-2">
<NuxtLink class="text-xl font-bold hover:text-accent-blue truncate" :to="{ name: 'character-id', params: { id: character.id } }" :title="character.name">{{ character.name }}</NuxtLink> <div class="flex flex-row gap-8 ps-4 items-center">
<span class="text-sm truncate">Niveau {{ character.level }}</span> <div class="flex flex-1 flex-col gap-2 justify-center">
<span class="text-lg font-bold group-hover:text-accent-blue">{{ character.name }}</span>
<span class="border-b w-full border-light-50 dark:border-dark-50"></span>
<div class="flex flex-row flex-1 items-stretch gap-4">
<span class="text-sm">Niveau {{ character.level }}</span>
<span class="w-px h-full bg-light-50 dark:bg-dark-50"></span>
<span class="text-sm italic">{{ config.peoples[character.people!]?.name }}</span>
</div>
</div>
<div class="rounded-full w-[96px] h-[96px] border border-light-50 dark:border-dark-50 bg-light-100 dark:bg-dark-100 !bg-opacity-10"></div>
</div>
</NuxtLink>
</div> </div>
</div> </div>
</div> <div v-else class="flex flex-col gap-2 items-center flex-1">
<span class="text-lg font-bold">Il n'existe pas encore de personnage public</span>
Soyez le premier à partager vos créations !
<NuxtLink class="inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50 py-2 px-4" :to="{ name: 'character-id-edit', params: { id: 'new' } }">Nouveau personnage</NuxtLink>
</div>
</template>
<div v-else> <div v-else>
<span>Erreur de chargement</span> <span>Erreur de chargement</span>
<span>{{ error?.message }}</span> <span>{{ error?.message }}</span>

View File

@@ -37,8 +37,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Content, Editor } from '#shared/content.util'; import { Content, Editor } from '#shared/content.util';
import { button, loading } from '#shared/components.util'; import { button, loading } from '#shared/components.util';
import { dom, icon, text } from '#shared/dom.util'; import { dom, icon } from '#shared/dom.util';
import { modal, popper, tooltip } from '#shared/floating.util'; import { modal, tooltip } from '#shared/floating.util';
import { Toaster } from '#shared/components.util'; import { Toaster } from '#shared/components.util';
definePageMeta({ definePageMeta({

View File

@@ -3,8 +3,8 @@
<Title>d[any] - Mentions légales</Title> <Title>d[any] - Mentions légales</Title>
</Head> </Head>
<div class="flex flex-col max-w-[1200px] p-16"> <div class="flex flex-col max-w-[1200px] p-16">
<ProseH3>Mentions Légales</ProseH3> <h3 class="text-xl font-bold">Mentions Légales</h3>
<ProseH4>Collecte et Traitement des Données Personnelles</ProseH4> <h4 class="text-lg font-semibold">Collecte et Traitement des Données Personnelles</h4>
Ce site collecte des données personnelles durant l'inscription et des données anonymes durant la navigation sur Ce site collecte des données personnelles durant l'inscription et des données anonymes durant la navigation sur
le site dans un but de collecte statistiques.<br /> le site dans un but de collecte statistiques.<br />
@@ -12,21 +12,21 @@
suppression de vos données personnelles. <br /> suppression de vos données personnelles. <br />
Pour exercer ces droits, vous pouvez vous rendre dans votre profil et selectionner l'option "Supprimer mon Pour exercer ces droits, vous pouvez vous rendre dans votre profil et selectionner l'option "Supprimer mon
compte" qui garanti une suppression de l'intégralité de vos données personnelles. compte" qui garanti une suppression de l'intégralité de vos données personnelles.<br /><br />
<ProseH4>Utilisation des Cookies</ProseH4> <h4 class="text-lg font-semibold">Utilisation des Cookies</h4>
Ce site utilise des cookies uniquement pour maintenir la connexion des utilisateurs et faciliter leur navigation Ce site utilise des cookies uniquement pour maintenir la connexion des utilisateurs et faciliter leur navigation
lors de chaque visite. Aucune information de suivi ou de profilage n'est réalisée. Ces cookies sont essentiels lors de chaque visite. Aucune information de suivi ou de profilage n'est réalisée. Ces cookies sont essentiels
au fonctionnement du site et ne nécessitent pas de consentement préalable. <br /> au fonctionnement du site et ne nécessitent pas de consentement préalable. <br />
Vous pouvez gérer les cookies en configurant les paramètres de votre navigateur, mais la désactivation de ces Vous pouvez gérer les cookies en configurant les paramètres de votre navigateur, mais la désactivation de ces
cookies pourrait affecter votre expérience de navigation. cookies pourrait affecter votre expérience de navigation.<br /><br />
<ProseH4>Limitation de Responsabilité</ProseH4> <h4 class="text-lg font-semibold">Limitation de Responsabilité</h4>
Les informations publiées sur ce site sont fournies à titre indicatif et peuvent contenir des erreurs. <br /> Les informations publiées sur ce site sont fournies à titre indicatif et peuvent contenir des erreurs. <br />
L'éditeur décline toute responsabilité quant à l'usage qui pourrait être fait de ces informations. L'éditeur décline toute responsabilité quant à l'usage qui pourrait être fait de ces informations.<br /><br />
<ProseH4>Propriété Intellectuelle</ProseH4> <h4 class="text-lg font-semibold">Propriété Intellectuelle</h4>
Tous les contenus présents sur ce site (textes, images, logos, etc.) sont protégés par les lois en vigueur Tous les contenus présents sur ce site (textes, images, logos, etc.) sont protégés par les lois en vigueur
sur la propriété intellectuelle. Toute reproduction ou utilisation de ces contenus sans autorisation préalable sur la propriété intellectuelle. Toute reproduction ou utilisation de ces contenus sans autorisation préalable
est interdite. <br /><br /> est interdite. <br /><br />

View File

@@ -1,53 +0,0 @@
<template>
<Head>
<Title>d[any] - Roadmap</Title>
</Head>
<div class="flex flex-col justify-start p-6">
<ProseH2>Roadmap</ProseH2>
<div class="grid grid-cols-4 gap-x-2 gap-y-4">
<div v-if="loggedIn && user && hasPermissions(user.permissions, ['admin'])" class="flex flex-col gap-2 justify-start">
<ProseH3>Administration</ProseH3>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Dashboard de statistiques</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Editeur de permissions</span><ProseTag>prioritaire</ProseTag></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Synchro project <-> GIT</span><ProseTag>prioritaire</ProseTag></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Versionning automatisé, releases et newsletter</span></Label>
</div>
<div class="flex flex-col gap-2 justify-start">
<ProseH3>Editeur</ProseH3>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition de page</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition riche de page</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Edition live de page</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Raccourcis d'edition</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Affichage alternatif par page</span></Label>
</div>
<div class="flex flex-col gap-2 justify-start">
<ProseH3>Projet</ProseH3>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Edition du projet</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Déplacement des fichiers</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Configuration de droit du projet</span><ProseTag>prioritaire</ProseTag></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Theme par projet</span></Label>
</div>
<div class="flex flex-col gap-2 justify-start">
<ProseH3>Nouvelles features</ProseH3>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Historique des modifs</span><ProseTag>prioritaire</ProseTag></Label><!-- Objet release: key hash, timestamp, version, name, description?. Objet edit: key hash, key property, value, timestamp -->
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Commentaire par page</span></Label><!-- Object comment: key path, key comment_id, position, content, owner, following? -->
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Type de fichier: Timeline</span></Label><!-- Propriétés: array of (from, (to || ponctual), ((title, content) || dedicated page)) -->
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Type de fichier: Whiteboard</span></Label><!-- Tableau de données SVG -->
</div>
<div class="flex flex-col gap-2 justify-start">
<ProseH3>Utilisateur</ProseH3>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" checked disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class="text-light-60 dark:text-dark-60 line-through">Validation du compte par mail<ProseTag>prioritaire</ProseTag></span></Label>
<!-- <Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Modification de profil</span></Label> -->
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Image de profil</span></Label>
<Label class="flex flex-row gap-2 items-center"><CheckboxRoot class="border border-light-35 dark:border-dark-35 w-6 h-6 flex justify-center items-center" disabled><CheckboxIndicator><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span class=" ">Préférence d'email</span></Label><!-- New features, newsletter et surveys -->
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import { hasPermissions } from '~/shared/auth.util';
const { loggedIn, user } = useUserSession();
</script>

45
pages/usage.vue Normal file
View File

@@ -0,0 +1,45 @@
<template>
<Head>
<Title>d[any] - Mentions légales</Title>
</Head>
<div class="flex flex-col max-w-[1200px] p-16">
<h3 class="text-xl font-bold">Conditions Générales d'Utilisation du site d-any.com</h3>
<h4 class="text-lg font-semibold py-2">1. Objet</h4>
Le site d-any.com offre un service en ligne dédié au jeu de rôle comprenant une section de règles officielles maintenues par l'administrateur, une section permettant la création de personnages
publics ou privés et une section de campagnes visant à rassembler plusieurs joueurs pour faire interagir leurs personnages. L'utilisation du site implique l'acceptation pleine et entière des présentes conditions. <br/><br/>
<h4 class="text-lg font-semibold py-2">2. Accès et fonctionnement</h4>
L'accès au site est gratuit. L'interaction entre utilisateurs est strictement limitée aux personnages et joueurs participant à une même campagne partagée. Aucun contact direct ni interaction n'est possible en dehors de cette structure.<br/><br/>
<h4 class="text-lg font-semibold py-2">3. Création et gestion des personnages</h4>
Les utilisateurs peuvent créer des personnages publics, visibles par tous les membres des campagnes partagées, ou privés, visibles uniquement par leur créateur.
Les utilisateurs sont responsables du contenu des personnages qu'ils créent. Ils s'engagent à ne pas créer ou publier des personnages portant atteinte à la dignité, contenant des propos discriminatoires, diffamatoires, obscènes ou illicites.
L'administrateur du site se réserve le droit de supprimer ou masquer tout personnage en infraction avec ces règles.<br/><br/>
<h4 class="text-lg font-semibold py-2">4. Règles du jeu</h4>
Les règles officielles du jeu, rédigées et entretenues par l'administrateur, doivent être respectées par tous les utilisateurs dans la création et le déroulement des campagnes.<br/><br/>
<h4 class="text-lg font-semibold py-2">5. Interaction en campagne</h4>
Les communications et interactions entre joueurs et personnages sont strictement limitées aux campagnes partagées.
Toute interaction dans ces cadres doit respecter les règles de respect, de courtoisie et de fair-play.
Tout comportement abusif, harcèlement, propos haineux ou toute forme de contenu illicite est prohibé et pourra entraîner des sanctions, incluant la suppression de comptes ou personnages.<br/><br/>
<h4 class="text-lg font-semibold py-2">6. Propriété intellectuelle</h4>
Les règles, outils, et contenus hébergés sur le site sont la propriété de l'administrateur ou des auteurs respectifs.
Les personnages créés appartiennent à leurs auteurs, sous réserve du respect des droits d'auteur liés au jeu original et de la charte du site.<br/><br/>
<h4 class="text-lg font-semibold py-2">7. Données personnelles</h4>
Les données collectées se limitent à celles nécessaires au fonctionnement du site. Toute donnée personnelle est traitée conformément à la réglementation en vigueur et peut être modifiée ou supprimée sur demande.<br/><br/>
<h4 class="text-lg font-semibold py-2">8. Responsabilité</h4>
L'administrateur ne pourra être tenu responsable des usages faits par les utilisateurs des personnages publics ou des interactions au sein des campagnes. L'éditeur décline toute responsabilité en cas d'abus
entre joueurs ou de contenu illégal diffusé par un utilisateur.<br/><br/>
<h4 class="text-lg font-semibold py-2">9. Modification des conditions</h4>
Ces conditions peuvent être modifiées à tout moment par l'administrateur. Les utilisateurs seront informés des modifications via le site et l'usage continu vaudra acceptation des nouvelles conditions.<br/><br/>
<h4 class="text-lg font-semibold py-2">10. Droit applicable</h4>
Les présentes conditions sont soumises au droit français. Tout litige sera porté devant les tribunaux compétents.<br/><br/>
<div class="py-32"></div>
</div>
</template>

View File

@@ -3,7 +3,7 @@
<Title>d[any] - Validation de votre adresse mail</Title> <Title>d[any] - Validation de votre adresse mail</Title>
</Head> </Head>
<div class="flex flex-col justify-center items-center"> <div class="flex flex-col justify-center items-center">
<ProseH2>Votre compte a été validé ! 🎉</ProseH2> <h2 class="text-2xl font-bold">Votre compte a été validé ! 🎉</h2>
<div class="flex flex-row gap-8"> <div class="flex flex-row gap-8">
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'user-login', replace: true }">Se connecter</NuxtLink></Button> <Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'user-login', replace: true }">Se connecter</NuxtLink></Button>
<Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'index', replace: true }">Retourner à l'accueil</NuxtLink></Button> <Button class="bg-light-25 dark:bg-dark-25"><NuxtLink :to="{ name: 'index', replace: true }">Retourner à l'accueil</NuxtLink></Button>

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center"> <div class="flex gap-8 items-center">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span> <span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<ProseH4>Reinitialisation de mon mot de passe</ProseH4> <h4 class="text-xl font-bold">Reinitialisation de mon mot de passe</h4>
</div> </div>
<form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch"> <form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch">
<TextInput type="text" label="Utilisateur ou email" autocomplete="username" v-model="email"/> <TextInput type="text" label="Utilisateur ou email" autocomplete="username" v-model="email"/>

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center"> <div class="flex gap-8 items-center">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span> <span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<ProseH4>Reinitialisation de mon mot de passe</ProseH4> <h4 class="text-center flex-1 text-xl font-bold">Reinitialisation de mon mot de passe</h4>
</div> </div>
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch"> <form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
<TextInput type="password" label="Nouveau mot de passe" autocomplete="newPassword" v-model="newPasswd" :class="{ '!border-light-red !dark:border-dark-red': error }"/> <TextInput type="password" label="Nouveau mot de passe" autocomplete="newPassword" v-model="newPasswd" :class="{ '!border-light-red !dark:border-dark-red': error }"/>

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center"> <div class="flex gap-8 items-center">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span> <span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<ProseH4>Modification de mon mot de passe</ProseH4> <h4 class="text-center flex-1 text-xl font-bold">Modification de mon mot de passe</h4>
</div> </div>
<form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch"> <form @submit.prevent="submit" class="flex flex-1 flex-col justify-center items-stretch">
<TextInput type="password" label="Ancien mot de passe" name="old-password" autocomplete="current-password" v-model="oldPasswd"/> <TextInput type="password" label="Ancien mot de passe" name="old-password" autocomplete="current-password" v-model="oldPasswd"/>

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center"> <div class="flex gap-8 items-center">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span> <span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<ProseH4>Connexion</ProseH4> <h4 class="text-xl font-bold">Connexion</h4>
</div> </div>
<form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch"> <form @submit.prevent="() => submit()" class="flex flex-1 flex-col justify-center items-stretch">
<TextInput type="text" label="Utilisateur ou email" name="username" autocomplete="username email" v-model="state.usernameOrEmail"/> <TextInput type="text" label="Utilisateur ou email" name="username" autocomplete="username email" v-model="state.usernameOrEmail"/>

View File

@@ -38,8 +38,8 @@ async function deleteUser()
<div class="flex gap-4"> <div class="flex gap-4">
<Avatar icon="radix-icons:person" :src="`/users/${user?.id}.medium.jpg`" class="w-32 h-32" /> <Avatar icon="radix-icons:person" :src="`/users/${user?.id}.medium.jpg`" class="w-32 h-32" />
<div class="flex flex-col items-start"> <div class="flex flex-col items-start">
<ProseH5>{{ user.username }}</ProseH5> <h4 class="text-xl font-bold">{{ user.username }}</h4>
<ProseH5>{{ user.email }}</ProseH5> <h4 class="text-xl font-bold">{{ user.email }}</h4>
</div> </div>
</div> </div>
<div class="border-light-red dark:border-dark-red bg-light-redBack dark:bg-dark-redBack text-light-red dark:text-dark-red py-1 px-3 flex items-center justify-between flex-col md:flex-row" <div class="border-light-red dark:border-dark-red bg-light-redBack dark:bg-dark-redBack text-light-red dark:text-dark-red py-1 px-3 flex items-center justify-between flex-col md:flex-row"

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center items-center"> <div class="flex flex-1 flex-col justify-center items-center">
<div class="flex gap-8 items-center"> <div class="flex gap-8 items-center">
<span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span> <span class="border border-transparent hover:border-light-35 dark:hover:border-dark-35 p-1 cursor-pointer" @click="() => $router.go(-1)"><Icon icon="radix-icons:arrow-left" class="text-light-50 dark:text-dark-50 w-6 h-6"/></span>
<ProseH4>Inscription</ProseH4> <h4 class="text-xl font-bold">Inscription</h4>
</div> </div>
<form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0"> <form @submit.prevent="() => submit()" class="grid flex-1 p-4 grid-cols-2 md:grid-cols-1 gap-4 md:gap-0">
<TextInput type="text" label="Nom d'utilisateur" name="username" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/> <TextInput type="text" label="Nom d'utilisateur" name="username" autocomplete="username" v-model="state.username" class="w-full md:w-auto"/>
@@ -20,6 +20,7 @@
<span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span> <span class="ps-4 flex items-center gap-2" :class="{'text-light-red dark:text-dark-red': !checkedSymbol}"><Icon v-show="!checkedSymbol" icon="radix-icons:cross-2" />Un caractère special</span>
</div> </div>
<TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/> <TextInput type="password" label="Confirmation du mot de passe" autocomplete="new-password" v-model="confirmPassword" class="w-full md:w-auto"/>
<Label class="pb-2 col-span-2 md:col-span-1 flex flex-row gap-2 items-center"><CheckboxRoot v-model:checked="agreeOnRules" class="border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 w-5 h-5" ><CheckboxIndicator ><Icon icon="radix-icons:check" /></CheckboxIndicator></CheckboxRoot><span>J'ai lu et j'accepte les <NuxtLink class="text-accent-blue cursor-pointer" :to="{ name: 'usage' }" target="_blank">conditions d'utilisation</NuxtLink></span></Label>
<Button type="submit" class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button> <Button type="submit" class="border border-light-35 dark:border-dark-35 max-w-48 w-full order-9 col-span-2 md:col-span-1 m-auto" :loading="status === 'pending'">S'inscrire</Button>
<span class="mt-4 order-10 flex justify-center items-center gap-4 col-span-2 md:col-span-1 m-auto">Vous avez déjà un compte ?<NuxtLink class="text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-login' }">Se connecter</NuxtLink></span> <span class="mt-4 order-10 flex justify-center items-center gap-4 col-span-2 md:col-span-1 m-auto">Vous avez déjà un compte ?<NuxtLink class="text-center block text-sm font-semibold tracking-wide hover:text-accent-blue" :to="{ name: 'user-login' }">Se connecter</NuxtLink></span>
</form> </form>
@@ -50,6 +51,7 @@ const checkedLower = computed(() => state.password.toUpperCase() !== state.passw
const checkedUpper = computed(() => state.password.toLowerCase() !== state.password); const checkedUpper = computed(() => state.password.toLowerCase() !== state.password);
const checkedDigit = computed(() => /[0-9]/.test(state.password)); const checkedDigit = computed(() => /[0-9]/.test(state.password));
const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e))); const checkedSymbol = computed(() => " !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~".split("").some(e => state.password.includes(e)));
const agreeOnRules = ref<boolean>(false);
const { data: result, status, error, refresh } = await useFetch('/api/auth/register', { const { data: result, status, error, refresh } = await useFetch('/api/auth/register', {
body: state, body: state,
@@ -57,7 +59,7 @@ const { data: result, status, error, refresh } = await useFetch('/api/auth/regis
method: 'POST', method: 'POST',
watch: false, watch: false,
ignoreResponseError: true, ignoreResponseError: true,
}) });
async function submit() async function submit()
{ {
@@ -69,6 +71,8 @@ async function submit()
return Toaster.add({ content: 'Veuillez saisir un mot de passe', timer: true, duration: 10000 }); return Toaster.add({ content: 'Veuillez saisir un mot de passe', timer: true, duration: 10000 });
if(state.password !== confirmPassword.value) if(state.password !== confirmPassword.value)
return Toaster.add({ content: 'Les deux mots de passe saisis ne correspondent pas', timer: true, duration: 10000 }); return Toaster.add({ content: 'Les deux mots de passe saisis ne correspondent pas', timer: true, duration: 10000 });
if(agreeOnRules.value !== true)
return Toaster.add({ content: 'Veuillez accepter des conditions d\'utilisations pour vous inscrire', timer: true, duration: 10000 });
const data = schema.safeParse(state); const data = schema.safeParse(state);

View File

@@ -4,7 +4,11 @@ declare module 'nitropack'
{ {
interface TaskPayload interface TaskPayload
{ {
type: string type: string;
}
interface TaskResult<RT = unknown>
{
error?: Error | string;
} }
} }
@@ -17,7 +21,7 @@ export default defineEventHandler(async (e) => {
return; return;
} }
const id = getRouterParam(e, 'id'); const id = getRouterParam(e, 'id');
const payload: Record<string, any> = await readBody(e); const body: Record<string, any> = await readBody(e);
if(!id) if(!id)
{ {
@@ -25,8 +29,11 @@ export default defineEventHandler(async (e) => {
return; return;
} }
payload.type = id; body.data = JSON.parse(body.data);
payload.data = JSON.parse(payload.data); const payload = {
type: id,
data: body,
}
const result = await runTask(id, { const result = await runTask(id, {
payload: payload payload: payload
@@ -36,7 +43,7 @@ export default defineEventHandler(async (e) => {
{ {
setResponseStatus(e, 500); setResponseStatus(e, 500);
if(result.error && (result.error as Error).message) if(result.error && result.error.message)
throw result.error; throw result.error;
else if(result.error) else if(result.error)
throw new Error(result.error); throw new Error(result.error);

View File

@@ -5,7 +5,6 @@ import { usersDataTable, usersTable } from '~/db/schema';
import { schema } from '~/schemas/registration'; import { schema } from '~/schemas/registration';
import { checkSession, logSession } from '~/server/utils/user'; import { checkSession, logSession } from '~/server/utils/user';
import type { UserSession, UserSessionRequired } from '~/types/auth'; import type { UserSession, UserSessionRequired } from '~/types/auth';
import sendMail from '~/server/tasks/mail';
import type { $ZodIssue } from 'zod/v4/core'; import type { $ZodIssue } from 'zod/v4/core';
interface SuccessHandler interface SuccessHandler
@@ -84,7 +83,7 @@ export default defineEventHandler(async (e): Promise<Return> => {
id: emailId, timestamp, id: emailId, timestamp,
} }
}); });
await sendMail({ await runTask('mail', {
payload: { payload: {
type: 'mail', type: 'mail',
to: [body.data.email], to: [body.data.email],

View File

@@ -1,9 +1,9 @@
import { and, eq, SQL, sql, type Operators } from 'drizzle-orm'; import { eq, SQL, type Operators } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase'; import useDatabase from '~/composables/useDatabase';
import { characterTable, userPermissionsTable } from '~/db/schema'; import { characterTable, userPermissionsTable } from '~/db/schema';
import { hasPermissions } from '~/shared/auth.util'; import { hasPermissions } from '~/shared/auth.util';
import { group } from '~/shared/general.util'; import { group } from '~/shared/general.util';
import type { Character, Level, MainStat, TrainingLevel } from '~/types/character'; import type { Character, MainStat, TrainingLevel } from '~/types/character';
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
let { visibility } = getQuery(e) as { visibility?: "public" | "own" | "admin" }; let { visibility } = getQuery(e) as { visibility?: "public" | "own" | "admin" };
@@ -16,16 +16,16 @@ export default defineEventHandler(async (e) => {
let where: ((character: typeof characterTable._.config.columns, sql: Operators) => SQL | undefined) | undefined = undefined; let where: ((character: typeof characterTable._.config.columns, sql: Operators) => SQL | undefined) | undefined = undefined;
const db = useDatabase(); const db = useDatabase();
const session = await getUserSession(e);
if(visibility === "own") if(visibility === "own")
{ {
const session = await getUserSession(e);
if(!session.user) if(!session.user)
{ {
setResponseStatus(e, 401); setResponseStatus(e, 401);
return; return;
} }
where = (character, { eq, and }) => and(eq(character.owner, session.user!.id), eq(character.visibility, "private")); where = (character, { eq, and }) => and(eq(character.owner, session.user!.id));
} }
else if(visibility === 'public') else if(visibility === 'public')
{ {
@@ -33,7 +33,6 @@ export default defineEventHandler(async (e) => {
} }
else if(visibility === 'admin') else if(visibility === 'admin')
{ {
const session = await getUserSession(e);
if(!session.user) if(!session.user)
{ {
setResponseStatus(e, 401); setResponseStatus(e, 401);
@@ -73,7 +72,7 @@ export default defineEventHandler(async (e) => {
people: character.people, people: character.people,
level: character.level, level: character.level,
aspect: character.aspect, aspect: character.aspect,
notes: character.notes, notes: { public: character.public_notes, private: session.user?.id === character.owner ? character.private_notes : undefined },
variables: character.variables, variables: character.variables,
training: character.training.reduce((p, v) => { p[v.stat] ??= {}; p[v.stat][v.level as TrainingLevel] = v.choice; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>), training: character.training.reduce((p, v) => { p[v.stat] ??= {}; p[v.stat][v.level as TrainingLevel] = v.choice; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>),

View File

@@ -31,18 +31,19 @@ export default defineEventHandler(async (e) => {
people: body.data.people!, people: body.data.people!,
level: body.data.level, level: body.data.level,
aspect: body.data.aspect, aspect: body.data.aspect,
notes: body.data.notes, public_notes: body.data.notes.public,
private_notes: body.data.notes.private,
variables: body.data.variables, variables: body.data.variables,
visibility: body.data.visibility, visibility: body.data.visibility,
thumbnail: body.data.thumbnail, thumbnail: body.data.thumbnail,
}).returning({ id: characterTable.id }).get().id; }).returning({ id: characterTable.id }).get().id;
if(Object.keys(body.data.leveling).length > 0) tx.insert(characterLevelingTable).values(Object.entries(body.data.leveling).map(e => ({ character: id, level: parseInt(e[0], 10), choice: e[1]! }))).run(); if(Object.keys(body.data.leveling).length > 0) tx.insert(characterLevelingTable).values(Object.entries(body.data.leveling).filter(e => e[1] !== undefined).map(e => ({ character: id, level: parseInt(e[0], 10), choice: e[1]! }))).run();
const training = Object.entries(body.data.training).flatMap(e => Object.entries(e[1]).map(_e => ({ character: id, stat: e[0] as MainStat, level: parseInt(_e[0], 10), choice: _e[1]! }))); const training = Object.entries(body.data.training).flatMap(e => Object.entries(e[1]).filter(e => e[1] !== undefined).map(_e => ({ character: id, stat: e[0] as MainStat, level: parseInt(_e[0], 10), choice: _e[1]! })));
if(training.length > 0) tx.insert(characterTrainingTable).values(training).run(); if(training.length > 0) tx.insert(characterTrainingTable).values(training).run();
const abilities = Object.entries(body.data.abilities).map(e => ({ character: id, ability: e[0] as Ability, value: e[1] })); const abilities = Object.entries(body.data.abilities).filter(e => e[1] !== undefined).map(e => ({ character: id, ability: e[0] as Ability, value: e[1] }));
if(abilities.length > 0) tx.insert(characterAbilitiesTable).values(abilities).run(); if(abilities.length > 0) tx.insert(characterAbilitiesTable).values(abilities).run();
return id; return id;

View File

@@ -43,7 +43,7 @@ export default defineEventHandler(async (e) => {
people: character.people, people: character.people,
level: character.level, level: character.level,
aspect: character.aspect, aspect: character.aspect,
notes: character.notes, notes: { public: character.public_notes, private: session.user?.id === character.owner ? character.private_notes : undefined },
variables: character.variables, variables: character.variables,
training: character.training.reduce((p, v) => { p[v.stat] ??= {}; p[v.stat][v.level as TrainingLevel] = v.choice; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>), training: character.training.reduce((p, v) => { p[v.stat] ??= {}; p[v.stat][v.level as TrainingLevel] = v.choice; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>),

View File

@@ -42,7 +42,8 @@ export default defineEventHandler(async (e) => {
people: body.data.people!, people: body.data.people!,
level: body.data.level, level: body.data.level,
aspect: body.data.aspect, aspect: body.data.aspect,
notes: body.data.notes, public_notes: body.data.notes.public,
private_notes: body.data.notes.private,
variables: body.data.variables, variables: body.data.variables,
visibility: body.data.visibility, visibility: body.data.visibility,
thumbnail: body.data.thumbnail, thumbnail: body.data.thumbnail,

View File

@@ -1,7 +1,7 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase'; import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema'; import { characterTable } from '~/db/schema';
import type { CharacterValues } from '~/types/character'; import { CharacterNotesValidation } from '#shared/character.util';
export default defineEventHandler(async (e) => { export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id"); const id = getRouterParam(e, "id");
@@ -11,11 +11,12 @@ export default defineEventHandler(async (e) => {
return; return;
} }
const body = await readBody(e) as CharacterValues; const body = await readValidatedBody(e, CharacterNotesValidation.safeParse);
if(!body) if(!body.success)
{ {
console.error(body.error);
setResponseStatus(e, 400); setResponseStatus(e, 400);
return; throw body.error;
} }
const db = useDatabase(); const db = useDatabase();
@@ -35,8 +36,8 @@ export default defineEventHandler(async (e) => {
} }
db.update(characterTable).set({ db.update(characterTable).set({
health: body.health, public_notes: body.data.public,
mana: body.mana, private_notes: body.data.private,
}).where(eq(characterTable.id, parseInt(id, 10))).run(); }).where(eq(characterTable.id, parseInt(id, 10))).run();
setResponseStatus(e, 200); setResponseStatus(e, 200);

View File

@@ -1,35 +0,0 @@
import { and, eq, sql } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema';
import type { CharacterVariables } from '~/types/character';
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");
if(!id)
{
setResponseStatus(e, 400);
return;
}
const session = await getUserSession(e);
if(!session.user)
{
setResponseStatus(e, 401);
return;
}
const db = useDatabase();
const character = db.select({
health: characterTable.health,
mana: characterTable.mana,
}).from(characterTable).where(and(eq(characterTable.id, parseInt(id, 10)), eq(characterTable.owner, session.user.id))).get();
if(character !== undefined)
{
return character as CharacterVariables;
}
setResponseStatus(e, 404);
return;
});

View File

@@ -0,0 +1,45 @@
import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema';
import { CharacterVariablesValidation } from '~/shared/character.util';
import type { CharacterVariables } from '~/types/character';
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");
if(!id)
{
setResponseStatus(e, 400);
return;
}
const body = await readValidatedBody(e, CharacterVariablesValidation.safeParse);
if(!body.success)
{
console.error(body.error);
setResponseStatus(e, 400);
throw body.error;
}
const db = useDatabase();
const old = db.select({ id: characterTable.id, owner: characterTable.owner }).from(characterTable).where(eq(characterTable.id, parseInt(id, 10))).get();
if(!old)
{
setResponseStatus(e, 404);
return;
}
const session = await getUserSession(e);
if(!session.user || old.owner !== session.user.id || session.user.state !== 1)
{
setResponseStatus(e, 401);
return;
}
db.update(characterTable).set({
variables: body.data
}).where(eq(characterTable.id, parseInt(id, 10))).run();
setResponseStatus(e, 200);
return;
});

View File

@@ -35,7 +35,7 @@ export default defineEventHandler(async (e) => {
return data.content; return data.content;
} }
return; return null;
} }
catch(_e) catch(_e)
{ {

View File

@@ -1,9 +1,9 @@
<template> <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; 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;"> <div style="margin-left: auto; margin-right: auto; text-align: center;">
<a href="https://d-any.com"> <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;" /> <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; 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> <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> </a>
</div> </div>
<div style="padding: 1rem;"> <div style="padding: 1rem;">
@@ -11,6 +11,6 @@
</div> </div>
</div> </div>
<div style="background-color: #171717;"> <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</p> <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> </div>
</template> </template>

View File

@@ -0,0 +1,25 @@
import { defineEventHandler, setResponseHeader } from 'h3';
export default defineEventHandler(async (event) => {
const acceptEncoding = event.headers.get('accept-encoding') || '';
if (!acceptEncoding.includes('zstd')) return;
const _end = event.node.res.end;
//@ts-expect-error
event.node.res.end = async (body: any, ...args: any[]) => {
const buffer = typeof body === "string" ? new TextEncoder().encode(body) : body;
if(buffer)
{
setResponseHeader(event, "Content-Encoding", "zstd");
setResponseHeader(event, "Vary", "Accept-Encoding");
//@ts-expect-error
_end.call(event.node.res, await Bun.zstdCompress(buffer), ...args);
}
else
{
//@ts-expect-error
_end.call(event.node.res, body, ...args);
}
};
});

View File

@@ -17,10 +17,9 @@ export const templates: Record<string, { component: any, subject: string }> = {
import type Mail from 'nodemailer/lib/mailer'; import type Mail from 'nodemailer/lib/mailer';
interface MailPayload interface MailPayload
{ {
type: 'mail' to: string[];
to: string[] template: string;
template: string data: Record<string, any>;
data: Record<string, any>
} }
const transport = nodemailer.createTransport({ const transport = nodemailer.createTransport({
@@ -55,51 +54,59 @@ if(process.env.NODE_ENV === 'production')
}); });
} }
export default async function(e: TaskEvent) { export default defineTask({
try { meta: {
if(e.payload.type !== 'mail') name: 'mail',
{ description: ''
throw new Error(`Données inconnues`); },
run: async ({ payload, context }) => {
try {
if(payload.type !== 'mail')
{
throw new Error(`Données inconnues`);
}
const mailPayload = payload.data as MailPayload;
const template = templates[mailPayload.template];
console.log(mailPayload);
if(!template)
{
throw new Error(`Modèle de mail ${mailPayload.template} inconnu`);
}
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),
subject: template.subject,
textEncoding: 'quoted-printable',
};
console.timeEnd('Generating HTML');
if(mail.html === '')
return { result: false, error: new Error("Invalid content") };
console.time('Sending Mail');
const status = await transport.sendMail(mail);
console.timeEnd('Sending Mail');
if(status.rejected.length > 0)
{
return { result: false, error: status.response, details: status.rejectedErrors };
}
return { result: true };
} }
catch(e)
const payload = e.payload as MailPayload;
const template = templates[payload.template];
if(!template)
{ {
throw new Error(`Modèle de mail ${payload.template} inconnu`); console.error(e);
return { result: false, error: e };
} }
console.time('Generating HTML');
const mail: Mail.Options = {
from: 'd[any] - Ne pas répondre <no-reply@peaceultime.com>',
to: payload.to,
html: await render(template.component, payload.data),
subject: template.subject,
textEncoding: 'quoted-printable',
};
console.timeEnd('Generating HTML');
if(mail.html === '')
return { result: false, error: new Error("Invalid content") };
console.time('Sending Mail');
const status = await transport.sendMail(mail);
console.timeEnd('Sending Mail');
if(status.rejected.length > 0)
{
return { result: false, error: status.response, details: status.rejectedErrors };
}
return { result: true };
} }
catch(e) })
{
console.error(e);
return { result: false, error: e };
}
}
async function render(component: any, data: Record<string, any>): Promise<string> async function render(component: any, data: Record<string, any>): Promise<string>
{ {

View File

@@ -105,8 +105,8 @@ function reshapeContent(content: string, type: FileType): string | null
return content; return content;
case "canvas": case "canvas":
const data = JSON.parse(content) as CanvasContent; const data = JSON.parse(content) as CanvasContent;
data.edges?.forEach(e => { console.log(e.color); e.color = typeof e.color === 'string' ? getColor(e.color) : undefined; console.log(e.color); }); data.edges?.forEach(e => e.color = typeof e.color === 'string' ? getColor(e.color) : undefined);
data.nodes?.forEach(e => { console.log(e.color); e.color = typeof e.color === 'string' ? getColor(e.color) : undefined; console.log(e.color); }); data.nodes?.forEach(e => e.color = typeof e.color === 'string' ? getColor(e.color) : undefined);
return JSON.stringify(data); return JSON.stringify(data);
default: default:
case 'folder': case 'folder':

View File

@@ -2,7 +2,7 @@ export function hasPermissions(userPermissions: string[], neededPermissions: str
{ {
for(let i = 0; i < neededPermissions.length; i++) for(let i = 0; i < neededPermissions.length; i++)
{ {
const list = neededPermissions[i].split(' '); const list = neededPermissions[i]!.split(' ');
if(list.every(e => userPermissions.includes(e))) if(list.every(e => userPermissions.includes(e)))
{ {

View File

@@ -1,13 +1,78 @@
import type { CanvasContent, CanvasEdge, CanvasNode } from "~/types/canvas"; import type { CanvasContent, CanvasEdge, CanvasNode } from "~/types/canvas";
import { clamp, lerp } from "#shared/general.util"; import { clamp, lerp } from "#shared/general.util";
import { dom, icon, svg, text } from "#shared/dom.util"; import { dom, icon, svg } from "#shared/dom.util";
import render from "#shared/markdown.util"; import render from "#shared/markdown.util";
import { popper, tooltip } from "#shared/floating.util"; import { tooltip } from "#shared/floating.util";
import { History } from "#shared/history.util"; import { History } from "#shared/history.util";
import { preview } from "#shared/proses"; import { preview } from "#shared/proses";
import { SpatialGrid } from "#shared/physics.util"; import { SpatialGrid } from "#shared/physics.util";
import type { CanvasPreferences } from "~/types/general"; import type { CanvasPreferences } from "~/types/general";
/*
stroke-light-red
stroke-light-orange
stroke-light-yellow
stroke-light-green
stroke-light-cyan
stroke-light-purple
dark:stroke-dark-red
dark:stroke-dark-orange
dark:stroke-dark-yellow
dark:stroke-dark-green
dark:stroke-dark-cyan
dark:stroke-dark-purple
fill-light-red
fill-light-orange
fill-light-yellow
fill-light-green
fill-light-cyan
fill-light-purple
dark:fill-dark-red
dark:fill-dark-orange
dark:fill-dark-yellow
dark:fill-dark-green
dark:fill-dark-cyan
dark:fill-dark-purple
bg-light-red
bg-light-orange
bg-light-yellow
bg-light-green
bg-light-cyan
bg-light-purple
dark:bg-dark-red
dark:bg-dark-orange
dark:bg-dark-yellow
dark:bg-dark-green
dark:bg-dark-cyan
dark:bg-dark-purple
border-light-red
border-light-orange
border-light-yellow
border-light-green
border-light-cyan
border-light-purple
dark:border-dark-red
dark:border-dark-orange
dark:border-dark-yellow
dark:border-dark-green
dark:border-dark-cyan
dark:border-dark-purple
outline-light-red
outline-light-orange
outline-light-yellow
outline-light-green
outline-light-cyan
outline-light-purple
dark:outline-dark-red
dark:outline-dark-orange
dark:outline-dark-yellow
dark:outline-dark-green
dark:outline-dark-cyan
dark:outline-dark-purple
*/
export type Direction = 'bottom' | 'top' | 'left' | 'right'; export type Direction = 'bottom' | 'top' | 'left' | 'right';
export type Position = { x: number, y: number }; export type Position = { x: number, y: number };
export type Box = Position & { width: number, height: number }; export type Box = Position & { width: number, height: number };
@@ -173,7 +238,6 @@ export class Node extends EventTarget
{ border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40` } { border: `border-light-40 dark:border-dark-40`, bg: `bg-light-40 dark:bg-dark-40` }
} }
} }
export class NodeEditable extends Node export class NodeEditable extends Node
{ {
edges: Set<EdgeEditable> = new Set(); edges: Set<EdgeEditable> = new Set();
@@ -418,6 +482,8 @@ export class Canvas
]), ]),
]), this.transform, ]), this.transform,
]); ]);
console.log(this.nodes.length, this.edges.length);
} }
protected computeLimits() protected computeLimits()
@@ -440,6 +506,7 @@ export class Canvas
mount() mount()
{ {
const dragMove = (e: MouseEvent) => { const dragMove = (e: MouseEvent) => {
e.preventDefault();
this.dragMove(e); this.dragMove(e);
}; };
const dragEnd = (e: MouseEvent) => { const dragEnd = (e: MouseEvent) => {
@@ -467,11 +534,11 @@ export class Canvas
this.firstX = pos.x; this.firstX = pos.x;
this.firstY = pos.y; this.firstY = pos.y;
window.addEventListener('mouseup', dragEnd, { passive: true }); window.addEventListener('mouseup', dragEnd);
window.addEventListener('mousemove', dragMove, { passive: true }); window.addEventListener('mousemove', dragMove);
this.dragStart(e); this.dragStart(e);
}, { passive: true }); });
this.container.addEventListener('wheel', (e) => { this.container.addEventListener('wheel', (e) => {
if((this._zoom >= Canvas.maxZoom && e.deltaY < 0) || (this._zoom <= this.containZoom && e.deltaY > 0)) if((this._zoom >= Canvas.maxZoom && e.deltaY < 0) || (this._zoom <= this.containZoom && e.deltaY > 0))
return; return;
@@ -490,10 +557,10 @@ export class Canvas
this.lastDistance = distance(e.touches); this.lastDistance = distance(e.touches);
} }
this.container.addEventListener('touchend', touchend, { passive: true }); this.container.addEventListener('touchend', touchend);
this.container.addEventListener('touchcancel', touchcancel, { passive: true }); this.container.addEventListener('touchcancel', touchcancel);
this.container.addEventListener('touchmove', touchmove, { passive: true }); this.container.addEventListener('touchmove', touchmove);
}, { passive: true }); });
const touchend = (e: TouchEvent) => { const touchend = (e: TouchEvent) => {
if(e.touches.length > 1) if(e.touches.length > 1)
{ {
@@ -515,6 +582,7 @@ export class Canvas
this.container.removeEventListener('touchmove', touchmove); this.container.removeEventListener('touchmove', touchmove);
}; };
const touchmove = (e: TouchEvent) => { const touchmove = (e: TouchEvent) => {
e.preventDefault();
const pos = center(e.touches); const pos = center(e.touches);
this._x = this.visualX = this._x - (this.lastX - pos.x) / this._zoom; this._x = this.visualX = this._x - (this.lastX - pos.x) / this._zoom;
this._y = this.visualY = this._y - (this.lastY - pos.y) / this._zoom; this._y = this.visualY = this._y - (this.lastY - pos.y) / this._zoom;
@@ -529,19 +597,22 @@ export class Canvas
this._zoom = clamp(this._zoom * diff, this.containZoom, Canvas.maxZoom); this._zoom = clamp(this._zoom * diff, this.containZoom, Canvas.maxZoom);
} }
this.updateTransform(); this.updateTransform(true);
}; };
this.computeLimits(); this.computeLimits();
this.reset(); this.reset();
} }
protected updateTransform() protected updateTransform(debounce: boolean)
{ {
this.transform.style.transform = `scale3d(${this.visualZoom}, ${this.visualZoom}, 1) translate3d(${this.visualX}px, ${this.visualY}px, 0)`; this.transform.style.transform = `scale3d(${this.visualZoom}, ${this.visualZoom}, 1) translate3d(${this.visualX}px, ${this.visualY}px, 0)`;
clearTimeout(this.debouncedTimeout); if(debounce)
this.debouncedTimeout = setTimeout(this.updateScale.bind(this), 150); {
clearTimeout(this.debouncedTimeout);
this.debouncedTimeout = setTimeout(this.updateScale.bind(this), 50);
}
} }
private updateScale() private updateScale()
@@ -562,7 +633,7 @@ export class Canvas
this.visualY = lerp(e, oldY, y); this.visualY = lerp(e, oldY, y);
this.visualZoom = lerp(e, oldZoom, zoom); this.visualZoom = lerp(e, oldZoom, zoom);
this.updateTransform(); this.updateTransform(true);
}, 50); }, 50);
} }
@@ -580,7 +651,7 @@ export class Canvas
this.lastX = e.clientX; this.lastX = e.clientX;
this.lastY = e.clientY; this.lastY = e.clientY;
this.updateTransform(); this.updateTransform(false);
} }
protected dragEnd(e: MouseEvent) {} protected dragEnd(e: MouseEvent) {}
@@ -846,9 +917,9 @@ export class CanvasEditor extends Canvas
e.stopImmediatePropagation(); e.stopImmediatePropagation();
} }
override updateTransform() override updateTransform(debounce: boolean = true)
{ {
super.updateTransform(); super.updateTransform(debounce);
this.pattern.parentElement?.classList.toggle('hidden', !this.preferences.value.gridSnap); this.pattern.parentElement?.classList.toggle('hidden', !this.preferences.value.gridSnap);
if(this.preferences.value.gridSnap) if(this.preferences.value.gridSnap)

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,15 @@
import type { Ability, Alignment, Character, CharacterConfig, CharacterVariables, CompiledCharacter, FeatureItem, Level, MainStat, Resistance, SpellElement, SpellType, TrainingLevel } from "~/types/character"; import type { Ability, Alignment, Character, CharacterConfig, CharacterVariables, CompiledCharacter, DamageType, FeatureItem, Level, MainStat, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, WeaponType } from "~/types/character";
import { z } from "zod/v4"; import { z } from "zod/v4";
import characterConfig from '#shared/character-config.json'; import characterConfig from '#shared/character-config.json';
import proses, { preview } from "#shared/proses"; import proses, { preview } from "#shared/proses";
import { button, buttongroup, foldable, input, loading, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util"; import { button, buttongroup, floater, foldable, input, loading, numberpicker, select, tabgroup, Toaster, toggle } from "#shared/components.util";
import { div, dom, icon, text } from "#shared/dom.util"; import { div, dom, icon, span, text } from "#shared/dom.util";
import { followermenu, fullblocker, tooltip } from "#shared/floating.util"; import { followermenu, fullblocker, tooltip } from "#shared/floating.util";
import { clamp } from "#shared/general.util"; import { clamp } from "#shared/general.util";
import markdown from "#shared/markdown.util"; import markdown from "#shared/markdown.util";
import { getText } from "./i18n"; import { getText } from "./i18n";
import type { User } from "~/types/auth"; import type { User } from "~/types/auth";
import { MarkdownEditor } from "./editor.util";
const config = characterConfig as CharacterConfig; const config = characterConfig as CharacterConfig;
@@ -21,6 +22,8 @@ export const CATEGORIES = ["action","reaction","freeaction","misc"] as const;
export const SPELL_ELEMENTS = ["fire","ice","thunder","earth","arcana","air","nature","light","psyche"] as const; export const SPELL_ELEMENTS = ["fire","ice","thunder","earth","arcana","air","nature","light","psyche"] as const;
export const ALIGNMENTS = ['loyal_good', 'neutral_good', 'chaotic_good', 'loyal_neutral', 'neutral_neutral', 'chaotic_neutral', 'loyal_evil', 'neutral_evil', 'chaotic_evil'] as const; export const ALIGNMENTS = ['loyal_good', 'neutral_good', 'chaotic_good', 'loyal_neutral', 'neutral_neutral', 'chaotic_neutral', 'loyal_evil', 'neutral_evil', 'chaotic_evil'] as const;
export const RESISTANCES = ['stun','bleed','poison','fear','influence','charm','possesion','precision','knowledge','instinct'] as const; export const RESISTANCES = ['stun','bleed','poison','fear','influence','charm','possesion','precision','knowledge','instinct'] as const;
export const DAMAGE_TYPES = ['slashing', 'piercing', 'bludgening', 'magic', 'fire', 'thunder', 'cold'] as const;
export const WEAPON_TYPES = ["light", "shield", "heavy", "classic", "throw", "natural", "twohanded", "finesse", "reach", "projectile"] as const;
export const defaultCharacter: Character = { export const defaultCharacter: Character = {
id: -1, id: -1,
@@ -29,7 +32,7 @@ export const defaultCharacter: Character = {
people: undefined, people: undefined,
level: 1, level: 1,
training: MAIN_STATS.reduce((p, v) => { p[v] = { 0: 0 }; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>), training: MAIN_STATS.reduce((p, v) => { p[v] = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 }; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>),
leveling: { 1: 0 }, leveling: { 1: 0 },
abilities: {}, abilities: {},
choices: {}, choices: {},
@@ -40,6 +43,7 @@ export const defaultCharacter: Character = {
items: [], items: [],
exhaustion: 0, exhaustion: 0,
sickness: [], sickness: [],
poisons: [],
}, },
owner: -1, owner: -1,
@@ -113,7 +117,10 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
magicelement: 0, magicelement: 0,
magicinstinct: 0, magicinstinct: 0,
}, },
bonus: {}, bonus: {
abilities: {},
defense: {},
},
resistance: {}, resistance: {},
initiative: 0, initiative: 0,
capacity: 0, capacity: 0,
@@ -125,7 +132,7 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
spells: [], spells: [],
}, },
aspect: "", aspect: "",
notes: character.notes ?? "", notes: Object.assign({ public: '', private: '' }, character.notes),
}); });
export const mainStatTexts: Record<MainStat, string> = { export const mainStatTexts: Record<MainStat, string> = {
@@ -158,6 +165,10 @@ export const elementTexts: Record<SpellElement, { class: string, text: string }>
light: { class: 'text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow', text: 'Lumière' }, light: { class: 'text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow', text: 'Lumière' },
psyche: { class: 'text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-purple bg-light-purple dark:bg-dark-purple', text: 'Psy' }, psyche: { class: 'text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-purple bg-light-purple dark:bg-dark-purple', text: 'Psy' },
}; };
export const elementDom = (element: SpellElement) => dom("span", {
class: [`border !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px`, elementTexts[element].class],
text: elementTexts[element].text
});
export const alignmentTexts: Record<Alignment, string> = { export const alignmentTexts: Record<Alignment, string> = {
'loyal_good': 'Loyal bon', 'loyal_good': 'Loyal bon',
@@ -204,30 +215,60 @@ export const resistanceTexts: Record<Resistance, string> = {
'knowledge': 'Sorts de savoir', 'knowledge': 'Sorts de savoir',
'instinct': 'Sorts d\'instinct', 'instinct': 'Sorts d\'instinct',
}; };
export const damageTypeTexts: Record<DamageType, string> = {
'bludgening': 'Contondant',
'cold': 'Froid',
'fire': 'Feu',
'magic': 'Magique',
'piercing': 'Perçant',
'slashing': 'Tranchant',
'thunder': 'Foudre',
};
export const weaponTypeTexts: Record<WeaponType, string> = {
"light": "Arme légère",
"shield": "Bouclier",
"heavy": "Arme lourde",
"classic": "Arme",
"throw": "Arme de jet",
"natural": "Arme naturelle",
"twohanded": "Deux mains",
"finesse": "Arme maniable",
"reach": "Arme longue",
"projectile": "Arme à projectile",
};
export const CharacterNotesValidation = z.object({
public: z.string().optional(),
private: z.string().optional(),
});
export const CharacterVariablesValidation = z.object({
health: z.number(),
mana: z.number(),
exhaustion: z.number(),
sickness: z.array(z.object({
id: z.string(),
state: z.number().min(1).max(7).or(z.literal(true)),
})),
poisons: z.array(z.object({
id: z.string(),
state: z.number().min(1).max(7).or(z.literal(true)),
})),
spells: z.array(z.string()),
items: z.array(z.string()),
});
export const CharacterValidation = z.object({ export const CharacterValidation = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
people: z.string().nullable(), people: z.string().nullable(),
level: z.number().min(1).max(20), level: z.number().min(1).max(20),
aspect: z.number().nullable().optional(), aspect: z.number().nullable().optional(),
notes: z.string().nullable().optional(), notes: CharacterNotesValidation,
training: z.record(z.enum(MAIN_STATS), z.record(z.enum(TRAINING_LEVELS.map(String)), z.number().optional())), training: z.record(z.enum(MAIN_STATS), z.record(z.enum(TRAINING_LEVELS.map(String)), z.number().optional())),
leveling: z.record(z.enum(LEVELS.map(String)), z.number().optional()), leveling: z.record(z.enum(LEVELS.map(String)), z.number().optional()),
abilities: z.record(z.enum(ABILITIES), z.number().optional()), abilities: z.record(z.enum(ABILITIES), z.number().optional()),
choices: z.record(z.string(), z.array(z.number())), choices: z.record(z.string(), z.array(z.number())),
variables: z.object({ variables: CharacterVariablesValidation,
health: z.number(),
mana: z.number(),
exhaustion: z.number(),
sickness: z.array(z.object({
id: z.string(),
state: z.number().min(1).max(7).or(z.literal(true)),
})),
spells: z.array(z.string()),
equipment: z.array(z.string()),
}),
owner: z.number(), owner: z.number(),
username: z.string().optional(), username: z.string().optional(),
visibility: z.enum(["public", "private"]), visibility: z.enum(["public", "private"]),
@@ -240,7 +281,16 @@ export class CharacterCompiler
{ {
protected _character!: Character; protected _character!: Character;
protected _result!: CompiledCharacter; protected _result!: CompiledCharacter;
protected _buffer: Record<string, PropertySum> = {}; protected _buffer: Record<string, PropertySum> = {
'modifier/strength': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/dexterity': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/constitution': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/intelligence': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/curiosity': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/charisma': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/psyche': { value: 0, _dirty: false, min: -Infinity, list: [] },
};
private _variableDirty: boolean = false;
constructor(character: Character) constructor(character: Character)
{ {
@@ -251,7 +301,15 @@ export class CharacterCompiler
{ {
this._character = value; this._character = value;
this._result = defaultCompiledCharacter(value); this._result = defaultCompiledCharacter(value);
this._buffer = {}; this._buffer = {
'modifier/strength': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/dexterity': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/constitution': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/intelligence': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/curiosity': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/charisma': { value: 0, _dirty: false, min: -Infinity, list: [] },
'modifier/psyche': { value: 0, _dirty: false, min: -Infinity, list: [] },
};
if(value.people !== undefined) if(value.people !== undefined)
{ {
@@ -299,6 +357,29 @@ export class CharacterCompiler
{ {
this._character.variables[prop] = value; this._character.variables[prop] = value;
this._result.variables[prop] = value; this._result.variables[prop] = value;
this._variableDirty = true;
}
saveVariables()
{
if(this._variableDirty)
{
this._variableDirty = false;
useRequestFetch()(`/api/character/${this.character.id}/variables`, {
method: 'POST',
body: this._character.variables,
}).then(() => {}).catch(() => {
Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true });
})
}
}
saveNotes()
{
return useRequestFetch()(`/api/character/${this.character.id}/notes`, {
method: 'POST',
body: this._character.notes,
}).then(() => {}).catch(() => {
Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true });
});
} }
protected add(feature?: string) protected add(feature?: string)
{ {
@@ -324,8 +405,8 @@ export class CharacterCompiler
case "list": case "list":
if(feature.action === 'add' && !this._result.lists[feature.list]!.includes(feature.item)) if(feature.action === 'add' && !this._result.lists[feature.list]!.includes(feature.item))
this._result.lists[feature.list]!.push(feature.item); this._result.lists[feature.list]!.push(feature.item);
else else if(feature.action === 'remove')
this._result.lists[feature.list] = this._result.lists[feature.list]!.filter((e: string) => e !== feature.item); this._result.lists[feature.list]!.splice(this._result.lists[feature.list]!.findIndex((e: string) => e === feature.item), 1);
return; return;
case "value": case "value":
@@ -336,6 +417,9 @@ export class CharacterCompiler
this._buffer[feature.property]!.min = -Infinity; this._buffer[feature.property]!.min = -Infinity;
this._buffer[feature.property]!._dirty = true; this._buffer[feature.property]!._dirty = true;
if(feature.property.startsWith('modifier/'))
Object.values(this._buffer).forEach(e => e._dirty = e.list.some(f => f.value === feature.property) ? true : e._dirty);
return; return;
case "choice": case "choice":
const choice = this._character.choices[feature.id]; const choice = this._character.choices[feature.id];
@@ -358,8 +442,8 @@ export class CharacterCompiler
case "list": case "list":
if(feature.action === 'remove' && !this._result.lists[feature.list]!.includes(feature.item)) if(feature.action === 'remove' && !this._result.lists[feature.list]!.includes(feature.item))
this._result.lists[feature.list]!.push(feature.item); this._result.lists[feature.list]!.push(feature.item);
else else if(feature.action === 'add')
this._result.lists[feature.list] = this._result.lists[feature.list]!.filter(e => e !== feature.item); this._result.lists[feature.list]!.splice(this._result.lists[feature.list]!.findIndex((e: string) => e === feature.item), 1)
return; return;
case "value": case "value":
@@ -370,6 +454,9 @@ export class CharacterCompiler
this._buffer[feature.property]!.min = -Infinity; this._buffer[feature.property]!.min = -Infinity;
this._buffer[feature.property]!._dirty = true; this._buffer[feature.property]!._dirty = true;
if(feature.property.startsWith('modifier/'))
Object.values(this._buffer).forEach(e => e._dirty = e.list.some(f => f.value === feature.property) ? true : e._dirty);
return; return;
case "choice": case "choice":
const choice = this._character.choices[feature.id]; const choice = this._character.choices[feature.id];
@@ -382,57 +469,53 @@ export class CharacterCompiler
return; return;
} }
} }
protected compile(properties: string[]) protected compile(queue: string[])
{ {
const queue = properties;
for(let i = 0; i < queue.length; i++) for(let i = 0; i < queue.length; i++)
{ {
if(queue[i] === undefined || queue[i] === "") continue;
const property = queue[i]!; const property = queue[i]!;
const buffer = this._buffer[property]; const buffer = this._buffer[property];
if(property === "")
continue
if(buffer && buffer._dirty === true) if(buffer && buffer._dirty === true)
{ {
let sum = 0, shortcut = false; let sum = 0, shortcut = false;
for(let i = 0; i < buffer.list.length; i++) for(let j = 0; j < buffer.list.length; j++)
{ {
if(typeof buffer.list[i]!.value === 'string') // Add or set a modifier const item = buffer.list[j];
if(!item)
continue;
if(typeof item.value === 'string') // Add or set a modifier
{ {
const modifier = this._buffer[buffer.list[i]!.value as string]; const modifier = this._buffer[item.value as string]!;
if(!modifier) if(modifier._dirty)
{
queue.push(property);
shortcut = true;
break;
}
else if(modifier._dirty)
{ {
//Put it back in queue since its dependencies haven't been resolved yet //Put it back in queue since its dependencies haven't been resolved yet
queue.push(buffer.list[i]!.value as string); queue.push(item.value as string);
queue.push(property); queue.push(property);
shortcut = true; shortcut = true;
break; break;
} }
else else
{ {
if(buffer.list[i]?.operation === 'add') if(item.operation === 'add')
sum += modifier.value; sum += modifier.value;
else if(buffer.list[i]?.operation === 'set') else if(item.operation === 'set')
sum = modifier.value; sum = modifier.value;
else if(buffer.list[i]?.operation === 'min') else if(item.operation === 'min')
this._buffer[property]!.min = modifier.value; buffer.min = modifier.value;
} }
} }
else else
{ {
if(buffer.list[i]?.operation === 'add') if(item.operation === 'add')
sum += buffer.list[i]!.value as number; sum += item.value as number;
else if(buffer.list[i]?.operation === 'set') else if(item.operation === 'set')
sum = buffer.list[i]!.value as number; sum = item.value as number;
else if(buffer.list[i]?.operation === 'min') else if(item.operation === 'min')
this._buffer[property]!.min = buffer.list[i]!.value as number; buffer.min = item.value as number;
} }
} }
@@ -493,6 +576,14 @@ export class CharacterBuilder extends CharacterCompiler
} }
private render() private render()
{ {
const publicNotes = new MarkdownEditor(), privateNotes = new MarkdownEditor();
this._character.notes ??= { public: '', private: '' };
publicNotes.onChange = (v) => this._character.notes!.public = this._result.notes.public = v;
privateNotes.onChange = (v) => this._character.notes!.private = this._result.notes.private = v;
publicNotes.content = this._character.notes.public!;
privateNotes.content = this._character.notes.private!;
this._steps = [ this._steps = [
PeoplePicker, PeoplePicker,
LevelPicker, LevelPicker,
@@ -510,7 +601,7 @@ export class CharacterBuilder extends CharacterCompiler
this._content = dom('div', { class: 'flex-1 outline-none max-w-full w-full overflow-y-auto', attributes: { id: 'characterEditorContainer' } }); this._content = dom('div', { class: 'flex-1 outline-none max-w-full w-full overflow-y-auto', attributes: { id: 'characterEditorContainer' } });
this._container.appendChild(div('flex flex-1 flex-col justify-start items-center px-8 w-full h-full overflow-y-hidden', [ this._container.appendChild(div('flex flex-1 flex-col justify-start items-center px-8 w-full h-full overflow-y-hidden', [
div("flex w-full flex-row gap-4 items-center justify-between px-4 bg-light-0 dark:bg-dark-0 z-20", [ div("flex w-full flex-row gap-4 items-center justify-between px-4 bg-light-0 dark:bg-dark-0 z-20", [
div(), div("flex w-full flex-row gap-4 items-center justify-center relative", this._stepsHeader), div(undefined, [ tooltip(icon("radix-icons:question-mark-circled", { height: 20, width: 20 }), this._helperText, "bottom-end") ]), div('flex flex-row gap-2', [ floater(tooltip(button(icon('radix-icons:pencil-2', { width: 16, height: 16 }), undefined, 'p-1'), 'Notes publics', 'left'), [ publicNotes.dom ], { pinned: true, events: { show: ['click'], hide: [] }, title: 'Notes publics', position: 'bottom-start' }), floater(tooltip(button(icon('radix-icons:eye-none', { width: 16, height: 16 }), undefined, 'p-1'), 'Notes privés', 'right'), [ privateNotes.dom ], { pinned: true, events: { show: ['click'], hide: [] }, title: 'Notes privés', position: 'bottom-start' }) ]), div("flex w-full flex-row gap-4 items-center justify-center relative", this._stepsHeader), div(undefined, [ tooltip(icon("radix-icons:question-mark-circled", { height: 20, width: 20 }), this._helperText, "bottom-end") ]),
]), ]),
this._content, this._content,
])); ]));
@@ -542,7 +633,7 @@ export class CharacterBuilder extends CharacterCompiler
} }
async save(leave: boolean = true) async save(leave: boolean = true)
{ {
if(this.id === 'new') if(this.id === 'new' || this.id === '-1')
{ {
//@ts-ignore //@ts-ignore
this.id = this._character.id = this._result.id = await useRequestFetch()(`/api/character`, { this.id = this._character.id = this._result.id = await useRequestFetch()(`/api/character`, {
@@ -1021,8 +1112,6 @@ class AbilityPicker extends BuilderTab
const values = builder.values, compiled = builder.compiled; const values = builder.values, compiled = builder.compiled;
const abilities = Object.values(builder.character.abilities).reduce((p, v) => p + v, 0); const abilities = Object.values(builder.character.abilities).reduce((p, v) => p + v, 0);
console.log(ABILITIES.map(e => (values[`bonus/abilities/${e}`] ?? 0) >= (compiled.abilities[e] ?? 0)));
return ABILITIES.map(e => (values[`bonus/abilities/${e}`] ?? 0) >= (compiled.abilities[e] ?? 0)).every(e => e) && (values.ability ?? 0) - abilities >= 0; return ABILITIES.map(e => (values[`bonus/abilities/${e}`] ?? 0) >= (compiled.abilities[e] ?? 0)).every(e => e) && (values.ability ?? 0) - abilities >= 0;
} }
} }
@@ -1142,10 +1231,13 @@ class AspectPicker extends BuilderTab
export class CharacterSheet export class CharacterSheet
{ {
user: ComputedRef<User | null>;
character?: CharacterCompiler; character?: CharacterCompiler;
container: HTMLElement = div(); container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center');
tabs?: HTMLDivElement & { refresh: () => void };
constructor(id: string, user: ComputedRef<User | null>) constructor(id: string, user: ComputedRef<User | null>)
{ {
this.user = user;
const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]); const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]);
this.container.replaceChildren(load); this.container.replaceChildren(load);
useRequestFetch()(`/api/character/${id}`).then(character => { useRequestFetch()(`/api/character/${id}`).then(character => {
@@ -1159,9 +1251,17 @@ export class CharacterSheet
this.render(); this.render();
} }
else else
{ throw new Error();
//ERROR }).catch((e) => {
} console.error(e);
this.container.replaceChildren(div('flex flex-col items-center justify-center flex-1 h-full gap-4', [
span('text-2xl font-bold tracking-wider', 'Personnage introuvable'),
span(undefined, 'Ce personnage n\'existe pas ou est privé.'),
div('flex flex-row gap-4 justify-center items-center', [
button(text('Personnages publics'), () => useRouter().push({ name: 'character-list' }), 'px-2 py-1'),
button(text('Créer un personange'), () => useRouter().push({ name: 'character-id-edit', params: { id: 'new' } }), 'px-2 py-1')
])
]))
}); });
} }
render() render()
@@ -1170,8 +1270,38 @@ export class CharacterSheet
return; return;
const character = this.character.compiled; const character = this.character.compiled;
console.log(character);
this.container.replaceChildren(div('flex flex-col justify-center gap-1', [ const publicNotes = new MarkdownEditor();
const privateNotes = new MarkdownEditor();
const loadableIcon = icon('radix-icons:paper-plane', { width: 16, height: 16 });
const saveLoading = loading('small');
const saveNotes = () => { loadableIcon.replaceWith(saveLoading); this.character?.saveNotes().finally(() => { saveLoading.replaceWith(loadableIcon) }); }
publicNotes.onChange = (v) => this.character!.character.notes!.public = v;
privateNotes.onChange = (v) => this.character!.character.notes!.private = v;
publicNotes.content = this.character!.character.notes!.public!;
privateNotes.content = this.character!.character.notes!.private!;
this.tabs = tabgroup([
{ id: 'actions', title: [ text('Actions') ], content: () => this.actionsTab(character) },
{ id: 'abilities', title: [ text('Aptitudes') ], content: () => this.abilitiesTab(character) },
{ id: 'spells', title: [ text('Sorts') ], content: () => this.spellTab(character) },
{ id: 'inventory', title: [ text('Inventaire') ], content: () => [
] },
{ id: 'notes', title: [ text('Notes') ], content: () => [
div('flex flex-col gap-2', [
div('flex flex-col gap-2 border-b border-light-35 dark:border-dark-35 pb-4', [ div('flex flex-row w-full items-center justify-between', [ span('text-lg font-bold', 'Notes publics'), tooltip(button(loadableIcon, saveNotes, 'p-1 items-center justify-center'), 'Enregistrer', 'right') ]), div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 h-64', [ publicNotes.dom ]) ]),
div('flex flex-col gap-2', [ span('text-lg font-bold', 'Notes privés'), div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 h-64', [ privateNotes.dom ]) ]),
])
] },
], { focused: 'abilities', class: { container: 'flex-1 gap-4 px-4 w-[960px] h-full', content: 'overflow-auto' } });
this.container.replaceChildren(div('flex flex-col justify-start gap-1 h-full', [
div("flex flex-row gap-4 justify-between", [ div("flex flex-row gap-4 justify-between", [
div(), div(),
@@ -1215,10 +1345,9 @@ export class CharacterSheet
]), ]),
div("self-center", [ div("self-center", [
/* user && user.id === character.owner ? this.user.value && this.user.value.id === character.owner ?
button(icon("radix-icons:pencil-2"), () => { button(icon("radix-icons:pencil-2"), () => useRouter().push({ name: 'character-id-edit', params: { id: this.character?.character.id } }), "p-1")
}, "icon") : div()
: div() */
]) ])
]), ]),
@@ -1295,156 +1424,167 @@ export class CharacterSheet
]), ]),
]), ]),
div("flex flex-1 flex-row items-stretch justify-center py-2 gap-4", [ div("flex flex-1 flex-row items-stretch justify-center py-2 gap-4 h-0", [
div("flex flex-col gap-4 py-1 w-80", [ div("flex flex-col gap-4 py-1 w-60", [
div("flex flex-col py-1 gap-4", [ div("flex flex-col py-1 gap-4", [
div("flex flex-row items-center justify-center gap-4", [ div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-semibold', text: "Compétences" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/l\'entrainement/competences', class: 'h-4' }) ]), div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-semibold', text: "Compétences" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/l\'entrainement/competences', label: 'Compétences', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50") div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50")
]), ]),
div("grid grid-cols-3 gap-2", div("grid grid-cols-2 gap-2",
Object.entries(character.abilities).map(([ability, value]) => Object.entries(character.abilities).map(([ability, value]) =>
div("flex flex-col px-2 items-center text-sm text-light-70 dark:text-dark-70", [ div("flex flex-row px-1 justify-between items-center", [
dom("span", { class: "font-bold text-base text-light-100 dark:text-dark-100", text: `+${value}` }), span("text-sm text-light-70 dark:text-dark-70 max-w-20 truncate", abilityTexts[ability as Ability] || ability),
dom("span", { text: abilityTexts[ability as Ability] || ability }) span("font-bold text-base text-light-100 dark:text-dark-100", `+${value}`),
]) ])
) )
), ),
div("flex flex-row items-center justify-center gap-4", [ div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-semibold', text: "Maitrises" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/l\'entrainement/competences', class: 'h-4' }) ]), div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-xl font-semibold', text: "Maitrises" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 16, height: 16, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/l\'entrainement/competences', label: 'Compétences', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50") div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50")
]), ]),
character.mastery.strength + character.mastery.dexterity > 0 ? div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ character.mastery.strength + character.mastery.dexterity > 0 ? div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [
character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme légère') ], { href: 'regles/annexes/equipement#Les armes légères' }) : undefined, character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme légère') ], { href: 'regles/annexes/equipement#Les armes légères', label: 'Arme légère' }) : undefined,
character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme de jet') ], { href: 'regles/annexes/equipement#Les armes de jet' }) : undefined, character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme de jet') ], { href: 'regles/annexes/equipement#Les armes de jet', label: 'Arme de jet' }) : undefined,
character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme naturelle') ], { href: 'regles/annexes/equipement#Les armes naturelles' }) : undefined, character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme naturelle') ], { href: 'regles/annexes/equipement#Les armes naturelles', label: 'Arme naturelle' }) : undefined,
character.mastery.strength > 1 ? proses('a', preview, [ text('Arme standard') ], { href: 'regles/annexes/equipement#Les armes' }) : undefined, character.mastery.strength > 1 ? proses('a', preview, [ text('Arme standard') ], { href: 'regles/annexes/equipement#Les armes', label: 'Arme standard' }) : undefined,
character.mastery.strength > 1 ? proses('a', preview, [ text('Arme improvisée') ], { href: 'regles/annexes/equipement#Les armes improvisées' }) : undefined, character.mastery.strength > 1 ? proses('a', preview, [ text('Arme improvisée') ], { href: 'regles/annexes/equipement#Les armes improvisées', label: 'Arme improvisée' }) : undefined,
character.mastery.strength > 2 ? proses('a', preview, [ text('Arme lourde') ], { href: 'regles/annexes/equipement#Les armes lourdes' }) : undefined, character.mastery.strength > 2 ? proses('a', preview, [ text('Arme lourde') ], { href: 'regles/annexes/equipement#Les armes lourdes', label: 'Arme lourde' }) : undefined,
character.mastery.strength > 3 ? proses('a', preview, [ text('Arme à deux mains') ], { href: 'regles/annexes/equipement#Les armes à deux mains' }) : undefined, character.mastery.strength > 3 ? proses('a', preview, [ text('Arme à deux mains') ], { href: 'regles/annexes/equipement#Les armes à deux mains', label: 'Arme à deux mains' }) : undefined,
character.mastery.dexterity > 0 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme maniable') ], { href: 'regles/annexes/equipement#Les armes maniables' }) : undefined, character.mastery.dexterity > 0 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme maniable') ], { href: 'regles/annexes/equipement#Les armes maniables', label: 'Arme maniable' }) : undefined,
character.mastery.dexterity > 1 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme à projectiles') ], { href: 'regles/annexes/equipement#Les armes à projectiles' }) : undefined, character.mastery.dexterity > 1 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme à projectiles') ], { href: 'regles/annexes/equipement#Les armes à projectiles', label: 'Arme à projectiles' }) : undefined,
character.mastery.dexterity > 1 && character.mastery.strength > 2 ? proses('a', preview, [ text('Arme longue') ], { href: 'regles/annexes/equipement#Les armes longues' }) : undefined, character.mastery.dexterity > 1 && character.mastery.strength > 2 ? proses('a', preview, [ text('Arme longue') ], { href: 'regles/annexes/equipement#Les armes longues', label: 'Arme longue' }) : undefined,
character.mastery.shield > 0 ? proses('a', preview, [ text('Bouclier') ], { href: 'regles/annexes/equipement#Les boucliers' }) : undefined, character.mastery.shield > 0 ? proses('a', preview, [ text('Bouclier') ], { href: 'regles/annexes/equipement#Les boucliers', label: 'Bouclier' }) : undefined,
character.mastery.shield > 0 && character.mastery.strength > 3 ? proses('a', preview, [ text('Bouclier à deux mains') ], { href: 'regles/annexes/equipement#Les boucliers à deux mains' }) : undefined, character.mastery.shield > 0 && character.mastery.strength > 3 ? proses('a', preview, [ text('Bouclier à deux mains') ], { href: 'regles/annexes/equipement#Les boucliers à deux mains', label: 'Bouclier à deux mains' }) : undefined,
]) : undefined, ]) : undefined,
character.mastery.armor > 0 ? div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ character.mastery.armor > 0 ? div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [
character.mastery.armor > 0 ? proses('a', preview, [ text('Armure légère') ], { href: 'regles/annexes/equipement#Les armures légères' }) : undefined, character.mastery.armor > 0 ? proses('a', preview, [ text('Armure légère') ], { href: 'regles/annexes/equipement#Les armures légères', label: 'Armure légère' }) : undefined,
character.mastery.armor > 1 ? proses('a', preview, [ text('Armure standard') ], { href: 'regles/annexes/equipement#Les armures' }) : undefined, character.mastery.armor > 1 ? proses('a', preview, [ text('Armure standard') ], { href: 'regles/annexes/equipement#Les armures', label: 'Armure standard' }) : undefined,
character.mastery.armor > 2 ? proses('a', preview, [ text('Armure lourde') ], { href: 'regles/annexes/equipement#Les armures lourdes' }) : undefined, character.mastery.armor > 2 ? proses('a', preview, [ text('Armure lourde') ], { href: 'regles/annexes/equipement#Les armures lourdes', label: 'Armure lourde' }) : undefined,
]) : undefined, ]) : undefined,
div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [ div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [
div('flex flex-row items-center gap-2', [ text('Précision'), dom('span', { text: character.spellranks.precision.toString(), class: 'font-bold' }) ]), div('flex flex-row items-center gap-2', [ text('Précision'), span('font-bold', character.spellranks.precision.toString()) ]),
div('flex flex-row items-center gap-2', [ text('Savoir'), dom('span', { text: character.spellranks.knowledge.toString(), class: 'font-bold' }) ]), div('flex flex-row items-center gap-2', [ text('Savoir'), span('font-bold', character.spellranks.knowledge.toString()) ]),
div('flex flex-row items-center gap-2', [ text('Instinct'), dom('span', { text: character.spellranks.instinct.toString(), class: 'font-bold' }) ]), div('flex flex-row items-center gap-2', [ text('Instinct'), span('font-bold', character.spellranks.instinct.toString()) ]),
div('flex flex-row items-center gap-2', [ text('Oeuvres'), dom('span', { text: character.spellranks.arts.toString(), class: 'font-bold' }) ]), div('flex flex-row items-center gap-2', [ text('Oeuvres'), span('font-bold', character.spellranks.arts.toString()) ]),
]) ])
]) ])
]), ]),
div('border-l border-light-35 dark:border-dark-35'), div('border-l border-light-35 dark:border-dark-35'),
tabgroup([ this.tabs,
{ id: 'actions', title: [ text('Actions') ], content: () => [
div('flex flex-col gap-8', [
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Actions', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
div('flex flex-row items-center gap-2', [ ...Array(character.action).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]),
]),
div('flex flex-col gap-2', [
div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Attaquer", "Désarmer", "Saisir", "Faire chuter", "Déplacer", "Courir", "Pas de coté", "Charger", "Lancer un sort", "S'interposer", "Se transformer", "Utiliser un objet", "Anticiper une action", "Improviser"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))),
...(character.lists.action?.map(e => div('flex flex-col gap-1', [
//div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: e.title }), e.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: e.cost }), text(`point${e.cost > 1 ? 's' : ''} d'action`)]) : undefined]),
markdown(getText(e), undefined, { tags: { a: preview } }),
])) ?? [])
]),
]),
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Réactions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Réaction', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
div('flex flex-row items-center gap-2', [ ...Array(character.reaction).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]),
]),
div('flex flex-col gap-2', [
div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Parer", "Esquiver", "Saisir une opportunité", "Prendre en tenaille", "Intercepter"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))),
...(character.lists.reaction?.map(e => div('flex flex-col gap-1', [
//div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: e.title }), e.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: e.cost }), text(`point${e.cost > 1 ? 's' : ''} d'action`)]) : undefined]),
markdown(getText(e), undefined, { tags: { a: preview } }),
])) ?? [])
]),
]),
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions libres" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Action libre', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
]),
div('flex flex-col gap-2', [
div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Analyser une situation", "Communiquer", "Dégainer", "Attraper un objet"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))),
...(character.lists.freeaction?.map(e => div('flex flex-col gap-1', [
//div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: e.title }), e.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: e.cost }), text(`action${e.cost > 1 ? 's' : ''} libre`)]) : undefined]),
markdown(getText(e), undefined, { tags: { a: preview } }),
])) ?? [])
]),
]),
]),
] },
{ id: 'abilities', title: [ text('Aptitudes') ], content: () => this.abilitiesTab(character) },
{ id: 'spells', title: [ text('Sorts') ], content: () => this.spellTab(character) },
{ id: 'inventory', title: [ text('Inventaire') ], content: () => [
] },
{ id: 'notes', title: [ text('Notes') ], content: () => [
] },
], { focused: 'abilities', class: { container: 'flex-1 gap-4 px-4 w-[960px]' } }),
]) ])
])); ]));
} }
actionsTab(character: CompiledCharacter)
{
return [
div('flex flex-col gap-8', [
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Actions', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
div('flex flex-row items-center gap-2', [ ...Array(character.action).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]),
]),
div('flex flex-col gap-2', [
div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Attaquer", "Désarmer", "Saisir", "Faire chuter", "Déplacer", "Courir", "Pas de coté", "Charger", "Lancer un sort", "S'interposer", "Se transformer", "Utiliser un objet", "Anticiper une action", "Improviser"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))),
...(character.lists.action?.map(e => div('flex flex-col gap-1', [
div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: config.action[e]?.name }), config.action[e]?.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: config.action[e]?.cost?.toString() }), text(`point${config.action[e]?.cost > 1 ? 's' : ''} d'action`)]) : undefined]),
markdown(getText(config.action[e]?.description), undefined, { tags: { a: preview } }),
])) ?? [])
]),
]),
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Réactions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Réaction', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
div('flex flex-row items-center gap-2', [ ...Array(character.reaction).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]),
]),
div('flex flex-col gap-2', [
div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Parer", "Esquiver", "Saisir une opportunité", "Prendre en tenaille", "Intercepter"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))),
...(character.lists.reaction?.map(e => div('flex flex-col gap-1', [
div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: config.reaction[e]?.name }), config.reaction[e]?.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: config.reaction[e]?.cost?.toString() }), text(`point${config.reaction[e]?.cost > 1 ? 's' : ''} d'action`)]) : undefined]),
markdown(getText(config.reaction[e]?.description), undefined, { tags: { a: preview } }),
])) ?? [])
]),
]),
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions libres" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Action libre', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
]),
div('flex flex-col gap-2', [
div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Analyser une situation", "Communiquer", "Dégainer", "Attraper un objet"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))),
...(character.lists.freeaction?.map(e => div('flex flex-col gap-1', [
div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: config.freeaction[e]?.name }) ]),
markdown(getText(config.freeaction[e]?.description), undefined, { tags: { a: preview } }),
])) ?? [])
]),
]),
]),
]
}
abilitiesTab(character: CompiledCharacter) abilitiesTab(character: CompiledCharacter)
{ {
return [ return [
div('flex flex-col gap-2', [ div('flex flex-col gap-2', [
...(character.lists.passive?.map(e => div('flex flex-col gap-1', [ ...(character.lists.passive?.map(e => div('flex flex-col gap-1', [
//div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: e.title }), e.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: e.cost }), text(`action${e.cost > 1 ? 's' : ''} libre`)]) : undefined]), div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: config.passive[e]?.name }) ]),
markdown(getText(e), undefined, { tags: { a: preview } }), markdown(getText(config.passive[e]?.description), undefined, { tags: { a: preview } }),
])) ?? []), ])) ?? []),
]), ]),
]; ];
} }
spellTab(character: CompiledCharacter) spellTab(character: CompiledCharacter)
{ {
let sortPreference = (localStorage.getItem('character-sort') ?? 'rank') as 'rank' | 'type' | 'element';
const sort = (spells: Array<{ id: string, spell?: SpellConfig, source: string }>) => {
spells = spells.filter(e => !!e.spell);
switch(sortPreference)
{
case 'rank': return spells.sort((a, b) => a.spell!.rank - b.spell!.rank || SPELL_ELEMENTS.indexOf(a.spell!.elements[0]!) - SPELL_ELEMENTS.indexOf(b.spell!.elements[0]!));
case 'type': return spells.sort((a, b) => a.spell!.type.localeCompare(b.spell!.type) || a.spell!.rank - b.spell!.rank);
case 'element': return spells.sort((a, b) => SPELL_ELEMENTS.indexOf(a.spell!.elements[0]!) - SPELL_ELEMENTS.indexOf(b.spell!.elements[0]!) || a.spell!.rank - b.spell!.rank);
default: return spells;
}
};
const spells = sort([...(character.lists.spells ?? []).map(e => ({ id: e, spell: config.spells.find(_e => _e.id === e), source: 'feature' })), ...character.variables.spells.map(e => ({ id: e, spell: config.spells.find(_e => _e.id === e), source: 'player' }))]).map(e => ({...e, dom:
e.spell ? div('flex flex-col gap-2', [
div('flex flex-row items-center gap-4', [ dom('span', { class: 'font-semibold text-lg', text: e.spell.name ?? 'Inconnu' }), div('flex-1 border-b border-dashed border-light-50 dark:border-dark-50'), dom('span', { class: 'text-light-70 dark:text-dark-70', text: `${e.spell.cost ?? 0} mana` }) ]),
div('flex flex-row justify-between items-center gap-2 text-light-70 dark:text-dark-70', [
div('flex flex-row gap-2', [ span('flex flex-row', e.spell.rank === 4 ? 'Sort unique' : `Sort ${e.spell.type === 'instinct' ? 'd\'instinct' : e.spell.type === 'knowledge' ? 'de savoir' : 'de précision'} de rang ${e.spell.rank}`), ...(e.spell.elements ?? []).map(elementDom) ]),
div('flex flex-row gap-2', [ e.spell.concentration ? proses('a', preview, [span('italic text-sm', 'concentration')], { href: '' }) : undefined, span(undefined, typeof e.spell.speed === 'number' ? `${e.spell.speed} minute${e.spell.speed > 1 ? 's' : ''}` : e.spell.speed) ])
]),
div('flex flex-row ps-4 p-1 border-l-4 border-light-35 dark:border-dark-35', [ markdown(e.spell.description) ]),
]) : undefined }));
return [ return [
div('flex flex-col gap-2', [ div('flex flex-col gap-2', [
div('flex flex-row justify-between items-center', [ div('flex flex-row justify-between items-center', [
div('flex flex-row gap-2 items-center', [ div('flex flex-row gap-2 items-center', [
dom('span', { class: 'italic tracking-tight text-sm', text: 'Trier par' }), dom('span', { class: 'italic tracking-tight text-sm', text: 'Trier par' }),
buttongroup([{ text: 'Rang', value: 'rank' }, { text: 'Type', value: 'type' }, { text: 'Element', value: 'element' }], { value: 'rank', class: { option: 'px-2 py-1 text-sm' } }), buttongroup<'rank' | 'type' | 'element'>([{ text: 'Rang', value: 'rank' }, { text: 'Type', value: 'type' }, { text: 'Element', value: 'element' }], { value: sortPreference, class: { option: 'px-2 py-1 text-sm' }, onChange: (value) => { localStorage.setItem('character-sort', value); sortPreference = value; this.tabs?.refresh(); } }),
]),
div('flex flex-row gap-2 items-center', [
dom('span', { class: ['italic text-sm', { 'text-light-red dark:text-dark-red': character.variables.spells.length !== character.spellslots }], text: `${character.variables.spells.length}/${character.spellslots} sort${character.variables.spells.length > 1 ? 's' : ''} maitrisé${character.variables.spells.length > 1 ? 's' : ''}` }),
button(text('Modifier'), () => this.spellPanel(character, spells), 'py-1 px-4'),
]) ])
]) ]),
div('flex flex-col gap-2', spells.map(e => e.dom))
]) ])
] ]
} }
spellPanel() spellPanel(character: CompiledCharacter, spelllist: Array<{ id: string, spell?: SpellConfig, source: string }>)
{ {
if(!this.character)
return;
const character = this.character.compiled;
const availableSpells = Object.values(config.spells).filter(spell => { const availableSpells = Object.values(config.spells).filter(spell => {
if (spell.rank === 4) return false; if (spell.rank === 4) return false;
if (character.spellranks[spell.type] < spell.rank) return false; if (character.spellranks[spell.type] < spell.rank) return false;
@@ -1465,21 +1605,21 @@ export class CharacterSheet
const toggleText = text(state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'), toggleButton = button(toggleText, () => { const toggleText = text(state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'), toggleButton = button(toggleText, () => {
if(state === 'choosen') if(state === 'choosen')
{ {
//this.character.variable('spells', character.variables.spells.filter(e => e !== spell.id)); //TO REWORK this.character!.variable('spells', character.variables.spells.filter(e => e !== spell.id));
state = 'empty'; state = 'empty';
} }
else if(state === 'empty') else if(state === 'empty')
{ {
//this.character.variable('spells', [...character.variables.spells, spell.id]); //TO REWORK this.character!.variable('spells', [...character.variables.spells, spell.id]); //TO REWORK
state = 'choosen'; state = 'choosen';
} }
//character = compiler.compiled; //TO REWORK
toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'; toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter';
textAmount.textContent = character.variables.spells.length.toString(); textAmount.textContent = character.variables.spells.length.toString();
this.tabs?.refresh();
}, "px-2 py-1 text-sm font-normal"); }, "px-2 py-1 text-sm font-normal");
toggleButton.disabled = state === 'given'; toggleButton.disabled = state === 'given';
return foldable(() => [ return foldable(() => [
markdown(spell.effect), markdown(spell.description),
], [ div("flex flex-row justify-between gap-2", [ ], [ div("flex flex-row justify-between gap-2", [
dom("span", { class: "text-lg font-bold", text: spell.name }), dom("span", { class: "text-lg font-bold", text: spell.name }),
div("flex flex-row items-center gap-6", [ div("flex flex-row items-center gap-6", [
@@ -1507,7 +1647,7 @@ export class CharacterSheet
]) ], { open: false, class: { container: "px-2 flex flex-col border-light-35 dark:border-dark-35", content: 'py-2' } }); ]) ], { open: false, class: { container: "px-2 flex flex-col border-light-35 dark:border-dark-35", content: 'py-2' } });
})) }))
]); ]);
const blocker = fullblocker([ container ], { closeWhenOutside: true }); const blocker = fullblocker([ container ], { closeWhenOutside: true, onClose: () => this.character?.saveVariables() });
setTimeout(() => container.setAttribute('data-state', 'active'), 1); setTimeout(() => container.setAttribute('data-state', 'active'), 1);
} }
} }

View File

@@ -1,10 +1,11 @@
import type { RouteLocationAsRelativeTyped, RouteMapGeneric } from "vue-router"; import type { RouteLocationAsRelativeTyped, RouteLocationRaw, RouteMapGeneric } from "vue-router";
import { type NodeProperties, type Class, type NodeChildren, dom, mergeClasses, text, div, icon, type Node } from "./dom.util"; import { type NodeProperties, type Class, type NodeChildren, dom, mergeClasses, text, div, icon, type Node } from "./dom.util";
import { contextmenu, followermenu } from "./floating.util"; import { contextmenu, followermenu, popper, tooltip, type FloatState } from "./floating.util";
import { clamp } from "./general.util"; import { clamp } from "./general.util";
import { Tree } from "./tree"; import { Tree } from "./tree";
import type { Placement } from "@floating-ui/dom";
export function link(properties?: NodeProperties & { active?: Class }, link?: RouteLocationAsRelativeTyped<RouteMapGeneric, string>, children?: NodeChildren) export function link(children: NodeChildren, properties?: NodeProperties & { active?: Class }, link?: RouteLocationAsRelativeTyped<RouteMapGeneric, string>)
{ {
const router = useRouter(); const router = useRouter();
const nav = link ? router.resolve(link) : undefined; const nav = link ? router.resolve(link) : undefined;
@@ -20,24 +21,32 @@ export function loading(size: 'small' | 'normal' | 'large' = 'normal'): HTMLElem
{ {
return dom('span', { class: ["after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin", {'w-6 h-6 border-4 border-transparent after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}] }) return dom('span', { class: ["after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin", {'w-6 h-6 border-4 border-transparent after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}] })
} }
export function async(size: 'small' | 'normal' | 'large' = 'normal', fn: Promise<HTMLElement>): HTMLElement export function async(size: 'small' | 'normal' | 'large' = 'normal', fn: Promise<HTMLElement>)
{ {
const load = loading(size); let state = { current: loading(size) };
fn.then((element) => { fn.then((element) => {
load.replaceWith(element); state.current.replaceWith(element);
state.current = element;
}).catch(e => { }).catch(e => {
console.error(e); console.error(e);
load.remove(); state.current.remove();
}) })
return load; return state;
} }
export function button(content: Node, onClick?: () => void, cls?: Class) export function button(content: Node, onClick?: (this: HTMLElement) => void, cls?: Class)
{ {
const btn = dom('button', { class: [`text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none /*
text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none
border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50`, cls], listeners: { click: () => disabled || (onClick && onClick()) } }, [ content ]); disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50
*/
const btn = dom('button', { class: [`inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50
disabled:text-light-50 dark:disabled:text-dark-50 disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-dashed disabled:border-light-40 dark:disabled:border-dark-40`, cls], listeners: { click: () => disabled || (onClick && onClick.bind(btn)()) } }, [ content ]);
let disabled = false; let disabled = false;
Object.defineProperty(btn, 'disabled', { Object.defineProperty(btn, 'disabled', {
get: () => disabled, get: () => disabled,
@@ -48,7 +57,7 @@ export function button(content: Node, onClick?: () => void, cls?: Class)
}) })
return btn; return btn;
} }
export function buttongroup<T extends any>(options: Array<{ text: string, value: T }>, settings?: { class?: { container?: Class, option?: Class }, value?: T, onChange?: (value: T) => boolean }) export function buttongroup<T extends any>(options: Array<{ text: string, value: T }>, settings?: { class?: { container?: Class, option?: Class }, value?: T, onChange?: (value: T) => boolean | void })
{ {
let currentValue = settings?.value; let currentValue = settings?.value;
const elements = options.map(e => dom('div', { class: [`cursor-pointer text-light-100 dark:text-dark-100 hover:bg-light-30 dark:hover:bg-dark-30 flex items-center justify-center bg-light-20 dark:bg-dark-20 leading-none outline-none const elements = options.map(e => dom('div', { class: [`cursor-pointer text-light-100 dark:text-dark-100 hover:bg-light-30 dark:hover:bg-dark-30 flex items-center justify-center bg-light-20 dark:bg-dark-20 leading-none outline-none
@@ -60,7 +69,7 @@ export function buttongroup<T extends any>(options: Array<{ text: string, value:
elements.forEach(e => e.toggleAttribute('data-selected', false)); elements.forEach(e => e.toggleAttribute('data-selected', false));
this.toggleAttribute('data-selected', true); this.toggleAttribute('data-selected', true);
if(!settings?.onChange || settings?.onChange(e.value)) if(!settings?.onChange || settings?.onChange(e.value) !== false)
{ {
currentValue = e.value; currentValue = e.value;
} }
@@ -68,6 +77,14 @@ export function buttongroup<T extends any>(options: Array<{ text: string, value:
}}})) }}}))
return div(['flex flex-row', settings?.class?.container], elements); return div(['flex flex-row', settings?.class?.container], elements);
} }
export function optionmenu(options: Array<{ title: string, click: () => void }>, settings?: { position?: Placement, class?: { container?: Class, option?: Class } }): (target?: HTMLElement) => void
{
let close: () => void;
const element = div(['flex flex-col divide-y divide-light-30 dark:divide-dark-30 text-light-100 dark:text-dark-100', settings?.class?.container], options.map(e => dom('div', { class: ['flex flex-row px-2 py-1 hover:bg-light-35 dark:hover:bg-dark-35 cursor-pointer', settings?.class?.option], text: e.title, listeners: { click: () => { e.click(); close() } } })));
return function(this: HTMLElement, target?: HTMLElement) {
close = followermenu(target ?? this, [ element ], { arrow: true, placement: settings?.position, offset: 8 }).close;
}
}
export type Option<T> = { text: string, render?: () => HTMLElement, value: T | Option<T>[] } | undefined; export type Option<T> = { text: string, render?: () => HTMLElement, value: T | Option<T>[] } | undefined;
type StoredOption<T> = { item: Option<T>, dom: HTMLElement, container?: HTMLElement, children?: Array<StoredOption<T>> }; type StoredOption<T> = { item: Option<T>, dom: HTMLElement, container?: HTMLElement, children?: Array<StoredOption<T>> };
export function select<T extends NonNullable<any>>(options: Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLElement export function select<T extends NonNullable<any>>(options: Array<{ text: string, value: T } | undefined>, settings?: { defaultValue?: T, change?: (value: T) => void, class?: { container?: Class, popup?: Class, option?: Class }, disabled?: boolean }): HTMLElement
@@ -383,9 +400,12 @@ export function numberpicker(settings?: { defaultValue?: number, change?: (value
} }
return false; return false;
} }
const field = dom("input", { attributes: { disabled: settings?.disabled }, class: [`w-14 mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20`, settings?.class], listeners: { const field = dom("input", { attributes: { disabled: settings?.disabled }, class: [`w-14 mx-4 caret-light-50 dark:caret-dark-50 text-light-100 dark:text-dark-100 placeholder:text-light-50 dark:placeholder:text-dark-50 bg-light-20 dark:bg-dark-20 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50 disabled:shadow-none disabled:bg-light-20 dark:disabled:bg-dark-20 disabled:border-dashed disabled:border-light-30 dark:disabled:border-dark-30`, settings?.class], listeners: {
input: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.input && settings.input(storedValue), input: () => validateAndChange(parseInt(field.value.trim().toLowerCase().normalize().replace(/[a-z,.]/g, ""), 10)) && settings?.input && settings.input(storedValue),
keydown: (e: KeyboardEvent) => { keydown: (e: KeyboardEvent) => {
if(field.disabled)
return;
switch(e.key) switch(e.key)
{ {
case "ArrowUp": case "ArrowUp":
@@ -420,12 +440,11 @@ export function foldable(content: NodeChildren | (() => NodeChildren), title: No
if(state && !_content) if(state && !_content)
{ {
_content = typeof content === 'function' ? content() : content; _content = typeof content === 'function' ? content() : content;
//@ts-ignore _content && contentContainer.replaceChildren(..._content.filter(e => !!e));
contentContainer.replaceChildren(..._content);
} }
} }
const contentContainer = div(['hidden group-data-[active]:flex', settings?.class?.content]); const contentContainer = div(['hidden group-data-[active]:flex', settings?.class?.content]);
const fold = div(['group flex flex-1 w-full flex-col', settings?.class?.container], [ const fold = div(['group flex w-full flex-col', settings?.class?.container], [
div('flex', [ dom('div', { listeners: { click: () => { display(fold.toggleAttribute('data-active')) } }, class: ['flex justify-center items-center', settings?.class?.icon] }, [ icon('radix-icons:caret-right', { class: 'group-data-[active]:rotate-90 origin-center', noobserver: true }) ]), div(['flex-1', settings?.class?.title], title) ]), div('flex', [ dom('div', { listeners: { click: () => { display(fold.toggleAttribute('data-active')) } }, class: ['flex justify-center items-center', settings?.class?.icon] }, [ icon('radix-icons:caret-right', { class: 'group-data-[active]:rotate-90 origin-center', noobserver: true }) ]), div(['flex-1', settings?.class?.title], title) ]),
contentContainer contentContainer
]); ]);
@@ -444,8 +463,11 @@ export function toggle(settings?: { defaultValue?: boolean, change?: (value: boo
let state = settings?.defaultValue ?? false; let state = settings?.defaultValue ?? false;
const element = dom("div", { class: [`group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none const element = dom("div", { class: [`group mx-3 w-12 h-6 select-none transition-all border border-light-35 dark:border-dark-35 bg-light-20 dark:bg-dark-20 outline-none
data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40 data-[state=checked]:bg-light-35 dark:data-[state=checked]:bg-dark-35 hover:border-light-50 dark:hover:border-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40
data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, settings?.class?.container], attributes: { "data-state": state ? "checked" : "unchecked" }, listeners: { data-[disabled]:bg-light-20 dark:data-[disabled]:bg-dark-20 data-[disabled]:border-light-20 dark:data-[disabled]:border-dark-20 relative py-[2px]`, settings?.class?.container], attributes: { "data-state": state ? "checked" : "unchecked", "data-disabled": settings?.disabled }, listeners: {
click: (e: Event) => { click: function(e: Event) {
if(this.hasAttribute('data-disabled'))
return;
state = !state; state = !state;
element.setAttribute('data-state', state ? "checked" : "unchecked"); element.setAttribute('data-state', state ? "checked" : "unchecked");
settings?.change && settings.change(state); settings?.change && settings.change(state);
@@ -454,25 +476,216 @@ export function toggle(settings?: { defaultValue?: boolean, change?: (value: boo
}, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]); }, [ div('block w-[18px] h-[18px] translate-x-[2px] will-change-transform transition-transform bg-light-50 dark:bg-dark-50 group-data-[state=checked]:translate-x-[26px] group-data-[disabled]:bg-light-30 dark:group-data-[disabled]:bg-dark-30 group-data-[disabled]:border-light-30 dark:group-data-[disabled]:border-dark-30') ]);
return element; return element;
} }
export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content: NodeChildren | (() => NodeChildren) }>, settings?: { focused?: string, class?: { container?: Class, tabbar?: Class, title?: Class, content?: Class } }) export function checkbox(settings?: { defaultValue?: boolean, change?: (this: HTMLElement, value: boolean) => void, disabled?: boolean, class?: { container?: Class, icon?: Class } })
{ {
const focus = settings?.focused ?? tabs[0]?.id; let state = settings?.defaultValue ?? false;
const element = dom("div", { class: [`group w-6 h-6 box-content flex items-center justify-center border border-light-50 dark:border-dark-50 bg-light-20 dark:bg-dark-20
cursor-pointer hover:bg-light-30 dark:hover:bg-dark-30 hover:border-light-60 dark:hover:border-dark-60
data-[disabled]:cursor-default data-[disabled]:border-dashed data-[disabled]:border-light-40 dark:data-[disabled]:border-dark-40 data-[disabled]:bg-0 dark:data-[disabled]:bg-0`, settings?.class?.container], attributes: { "data-state": state ? "checked" : "unchecked", "data-disabled": settings?.disabled }, listeners: {
click: function(e: Event) {
if(this.hasAttribute('data-disabled'))
return;
state = !state;
element.setAttribute('data-state', state ? "checked" : "unchecked");
settings?.change && settings.change.bind(this)(state);
}
}
}, [ icon('radix-icons:check', { width: 14, height: 14, class: ['hidden group-data-[state="checked"]:block data-[disabled]:text-light-50 dark:data-[disabled]:text-dark-50', settings?.class?.icon] }), ]);
return element;
}
export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content: NodeChildren | (() => NodeChildren) }>, settings?: { focused?: string, class?: { container?: Class, tabbar?: Class, title?: Class, content?: Class } }): HTMLDivElement & { refresh: () => void }
{
let focus = settings?.focused ?? tabs[0]?.id;
const titles = tabs.map((e, i) => dom('div', { class: ['px-2 py-1 border-b border-transparent hover:border-accent-blue data-[focus]:border-accent-blue data-[focus]:border-b-[3px] cursor-pointer', settings?.class?.title], attributes: { 'data-focus': e.id === focus }, listeners: { click: function() { const titles = tabs.map((e, i) => dom('div', { class: ['px-2 py-1 border-b border-transparent hover:border-accent-blue data-[focus]:border-accent-blue data-[focus]:border-b-[3px] cursor-pointer', settings?.class?.title], attributes: { 'data-focus': e.id === focus }, listeners: { click: function() {
if(this.hasAttribute('data-focus')) if(this.hasAttribute('data-focus'))
return; return;
titles.forEach(e => e.toggleAttribute('data-focus', false)); titles.forEach(e => e.toggleAttribute('data-focus', false));
this.toggleAttribute('data-focus', true); this.toggleAttribute('data-focus', true);
focus = e.id;
const _content = typeof e.content === 'function' ? e.content() : e.content; const _content = typeof e.content === 'function' ? e.content() : e.content;
//@ts-expect-error _content && content.replaceChildren(..._content?.filter(e => !!e));
content.replaceChildren(..._content);
}}}, e.title)); }}}, e.title));
const _content = tabs.find(e => e.id === focus)?.content; const _content = tabs.find(e => e.id === focus)?.content;
const content = div(['', settings?.class?.content], typeof _content === 'function' ? _content() : _content); const content = div(['', settings?.class?.content], typeof _content === 'function' ? _content() : _content);
return div(['flex flex-col', settings?.class?.container], [
const container = div(['flex flex-col', settings?.class?.container], [
div(['flex flex-row items-center gap-1', settings?.class?.tabbar], titles), div(['flex flex-row items-center gap-1', settings?.class?.tabbar], titles),
content content
]); ]);
Object.defineProperty(container, 'refresh', {
writable: false,
configurable: false,
enumerable: false,
value: () => {
let _content = tabs.find(e => e.id === focus)?.content;
_content = (typeof _content === 'function' ? _content() : _content);
_content && content.replaceChildren(..._content.filter(e => !!e));
}
})
return container as HTMLDivElement & { refresh: () => void };
}
export function floater(container: HTMLElement, content: NodeChildren | (() => NodeChildren), settings?: { href?: RouteLocationRaw, class?: Class, position?: Placement, pinned?: boolean, minimizable?: boolean, cover?: 'width' | 'height' | 'all' | 'none', events?: { show: Array<keyof HTMLElementEventMap>, hide: Array<keyof HTMLElementEventMap>, onshow?: (state: FloatState) => boolean, onhide?: (state: FloatState) => boolean }, title?: string })
{
let viewport = document.getElementById('mainContainer') ?? undefined;
let diffX, diffY;
let minimizeBox: DOMRect, minimized = false;
const events: { show: Array<keyof HTMLElementEventMap>, hide: Array<keyof HTMLElementEventMap>, onshow?: (this: HTMLElement, state: FloatState) => boolean, onhide?: (this: HTMLElement, state: FloatState) => boolean } = Object.assign({
show: ['mouseenter', 'mousemove', 'focus'],
hide: ['mouseleave', 'blur'],
}, settings?.events ?? {});
if(settings?.pinned)
{
events.onshow = (state) => {
if(!settings?.events?.onshow || settings?.events?.onshow(state))
{
floating.show();
pin();
}
return false;
};
}
const dragstart = (e: MouseEvent) => {
e.preventDefault();
if(minimized)
return;
window.addEventListener('mousemove', dragmove);
window.addEventListener('mouseup', dragend);
const box = floating.content.getBoundingClientRect();
diffX = e.clientX - box.x;
diffY = e.clientY - box.y;
};
const resizestart = (e: MouseEvent) => {
e.preventDefault();
window.addEventListener('mousemove', resizemove);
window.addEventListener('mouseup', resizeend);
};
const dragmove = (e: MouseEvent) => {
const box = floating.content.getBoundingClientRect();
const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight };
box.x = clamp(e.clientX - diffX!, viewbox?.left ?? 0, viewbox.right - box.width);
box.y = clamp(e.clientY - diffY!, viewbox?.top ?? 0, viewbox.bottom - box.height);
Object.assign(floating.content.style, {
left: `${box.x}px`,
top: `${box.y}px`,
});
};
const resizemove = (e: MouseEvent) => {
const box = floating.content.getBoundingClientRect();
const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight };
box.width = clamp(e.clientX - box.x, 200, Math.min(750, viewbox.right - box.x));
box.height = clamp(e.clientY - box.y, 150, Math.min(750, viewbox.bottom - box.y));
Object.assign(floating.content.style, {
width: `${box.width}px`,
height: `${box.height}px`,
});
};
const dragend = (e: MouseEvent) => {
e.preventDefault();
window.removeEventListener('mousemove', dragmove);
window.removeEventListener('mouseup', dragend);
};
const resizeend = (e: MouseEvent) => {
e.preventDefault();
window.removeEventListener('mousemove', resizemove);
window.removeEventListener('mouseup', resizeend);
};
const pin = () => {
if(floating.content.hasAttribute('data-pinned'))
return;
const box = floating.content.children.item(0)!.getBoundingClientRect();
const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight };
Object.assign(floating.content.style, {
left: `${clamp(box.left, viewbox.left, viewbox.right)}px`,
top: `${clamp(box.top, viewbox.top, viewbox.bottom)}px`,
width: `${box.width + 21}px`,
height: `${box.height + 21}px`,
});
floating.content.attributeStyleMap.delete('bottom');
floating.content.attributeStyleMap.delete('right');
floating.stop();
floating.content.addEventListener('mousedown', function() {
if(!floating.content.hasAttribute('data-pinned'))
return;
[...this.parentElement?.children ?? []].forEach(e => (e as any as HTMLElement).attributeStyleMap.set('z-index', -1));
this.attributeStyleMap.set('z-index', 0);
}, { passive: true });
}
const minimize = () => {
minimized = !minimized;
floating.content.toggleAttribute('data-minimized', minimized);
if(minimized)
{
minimizeBox = floating.content.getBoundingClientRect();
Object.assign(floating.content.style, {
left: `0px`,
top: `initial`,
bottom: `0px`,
width: `150px`,
height: `21px`,
});
}
else
{
Object.assign(floating.content.style, {
left: `${minimizeBox.left}px`,
top: `${minimizeBox.top}px`,
width: `${minimizeBox.width}px`,
height: `${minimizeBox.height}px`,
});
}
};
const floating = popper(container, {
arrow: true,
delay: settings?.pinned ? 0 : 150,
offset: 12,
cover: settings?.cover,
placement: settings?.position,
class: 'bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 group-data-[pinned]:bg-light-15 dark:group-data-[pinned]:bg-dark-15 group-data-[pinned]:border-light-50 dark:group-data-[pinned]:border-dark-50 text-light-100 dark:text-dark-100 z-[45] relative group-data-[pinned]:h-full',
content: () => [
settings?.pinned !== undefined ? div('hidden group-data-[pinned]:flex flex-row items-center border-b border-light-35 dark:border-dark-35', [
dom('span', { class: 'flex-1 w-full h-full cursor-move group-data-[minimized]:cursor-default text-xs px-2', listeners: { mousedown: dragstart }, text: (settings?.title?.substring(0, 1)?.toUpperCase() ?? '') + (settings?.title?.substring(1)?.toLowerCase() ?? '') }),
settings?.title ? tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { click: minimize } }, [icon('radix-icons:minus', { width: 12, height: 12, class: 'p-1' })]), text('Réduire'), 'top') : undefined,
settings?.href ? tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { mousedown: (e) => { ((e.ctrlKey || e.button === 1) ? window.open : useRouter().push)(useRouter().resolve(settings.href!).href); floating.hide(); } } }, [icon('radix-icons:external-link', { width: 12, height: 12, class: 'p-1' })]), 'Ouvrir', 'top') : undefined,
tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { mousedown: (e) => {
e.stopImmediatePropagation();
floating.hide();
floating.content.toggleAttribute('data-minimized', false);
minimized && Object.assign(floating.content.style, {
left: `${minimizeBox.left}px`,
top: `${minimizeBox.top}px`,
width: `${minimizeBox.width}px`,
height: `${minimizeBox.height}px`,
});
minimized = false;
} } }, [icon('radix-icons:cross-1', { width: 12, height: 12, class: 'p-1' })]), 'Fermer', 'top') ]) : undefined,
div('group-data-[minimized]:hidden h-full group-data-[pinned]:h-[calc(100%-21px)] w-full min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] group-data-[pinned]:min-h-[initial] group-data-[pinned]:min-w-[initial] group-data-[pinned]:max-h-[initial] group-data-[pinned]:max-w-[initial] overflow-auto box-content', typeof content === 'function' ? content() : content), dom('span', { class: 'hidden group-data-[pinned]:flex group-data-[minimized]:hidden absolute bottom-0 right-0 cursor-nw-resize z-50', listeners: { mousedown: resizestart } }, [ icon('ph:notches', { width: 12, height: 12 }) ])
],
viewport,
events: events
});
if(settings?.pinned === false)
floating.content.addEventListener('dblclick', pin);
return container;
} }
export interface ToastConfig export interface ToastConfig
@@ -488,13 +701,13 @@ type ToastDom = ToastConfig & { dom: HTMLElement };
export type ToastType = 'info' | 'success' | 'error'; export type ToastType = 'info' | 'success' | 'error';
export class Toaster export class Toaster
{ {
private static _MAX_DRAG = 130; private static _MAX_DRAG = 150;
private static _list: Array<ToastDom> = []; private static _list: Array<ToastDom> = [];
private static _container: HTMLDivElement; private static _container: HTMLDivElement;
static init() static init()
{ {
Toaster._container = div('fixed bottom-0 right-0 flex flex-col p-6 gap-2 max-w-[512px] z-50 outline-none min-w-72'); Toaster._container = dom('div', { attributes: { id: 'toaster' }, class: 'fixed bottom-0 right-0 flex flex-col p-6 gap-2 max-w-[512px] z-50 outline-none min-w-72' });
document.body.appendChild(Toaster._container); document.body.appendChild(Toaster._container);
} }
static add(_config: ToastConfig) static add(_config: ToastConfig)

View File

@@ -124,6 +124,9 @@ export class Content
if(Content._ready) if(Content._ready)
return Promise.resolve(true); return Promise.resolve(true);
if(Content.initPromise)
return Content.initPromise;
Content.initPromise = new Promise(async (res) => { Content.initPromise = new Promise(async (res) => {
try try
{ {
@@ -764,11 +767,11 @@ export class Editor
if (location.current.dropTargets.length === 0) if (location.current.dropTargets.length === 0)
return; return;
const target = location.current.dropTargets[0]; const target = location.current.dropTargets[0]!;
const instruction = extractInstruction(target.data); const instruction = extractInstruction(target.data);
if (instruction !== null) if (instruction !== null)
this.updateTree(instruction, location.initial.dropTargets[0].data.id as string, target.data.id as string); this.updateTree(instruction, location.initial.dropTargets[0]!.data.id as string, target.data.id as string);
}, },
}), autoScrollForElements({ }), autoScrollForElements({
element: this.tree.container, element: this.tree.container,

View File

@@ -1,7 +1,7 @@
import { iconExists, loadIcon } from 'iconify-icon'; import { iconExists, loadIcon } from 'iconify-icon';
export type Node = HTMLElement | SVGElement | Text | undefined; export type Node = HTMLElement | SVGElement | Text | undefined;
export type NodeChildren = Array<Node>; export type NodeChildren = Array<Node> | undefined;
export type Class = string | Array<Class> | Record<string, boolean> | undefined; export type Class = string | Array<Class> | Record<string, boolean> | undefined;
type Listener<K extends keyof HTMLElementEventMap> = | ((this: HTMLElement, ev: HTMLElementEventMap[K]) => any) | { type Listener<K extends keyof HTMLElementEventMap> = | ((this: HTMLElement, ev: HTMLElementEventMap[K]) => any) | {
@@ -56,11 +56,11 @@ export function div(cls?: Class, children?: NodeChildren): HTMLDivElement
{ {
return dom("div", { class: cls }, children); return dom("div", { class: cls }, children);
} }
export function span(cls?: Class, children?: NodeChildren): HTMLSpanElement export function span(cls?: Class, text?: string): HTMLSpanElement
{ {
return dom("span", { class: cls }, children); return dom("span", { class: cls, text: text });
} }
export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: Omit<NodeChildren, 'HTMLElement' | 'Text'>): SVGElementTagNameMap[K] export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: SVGElement[]): SVGElementTagNameMap[K]
{ {
const element = document.createElementNS("http://www.w3.org/2000/svg", tag); const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
@@ -120,45 +120,45 @@ export interface IconProperties
const iconCache: Map<string, HTMLElement> = new Map(); const iconCache: Map<string, HTMLElement> = new Map();
export function icon(name: string, properties?: IconProperties): HTMLElement export function icon(name: string, properties?: IconProperties): HTMLElement
{ {
let el; let element;
if(iconCache.has(name)) if(iconCache.has(name))
el = iconCache.get(name)!.cloneNode() as HTMLElement; element = iconCache.get(name)!.cloneNode() as HTMLElement;
else else
{ {
el = document.createElement('iconify-icon'); element = document.createElement('iconify-icon');
if(!iconExists(name)) if(!iconExists(name))
loadIcon(name); loadIcon(name);
el.setAttribute('icon', name); element.setAttribute('icon', name);
iconCache.set(name, el.cloneNode() as HTMLElement); iconCache.set(name, element.cloneNode() as HTMLElement);
} }
properties?.mode && el.setAttribute('mode', properties?.mode.toString()); properties?.mode && element.setAttribute('mode', properties?.mode.toString());
properties?.inline && el.toggleAttribute('inline', properties?.inline); properties?.inline && element.toggleAttribute('inline', properties?.inline);
properties?.noobserver && el.toggleAttribute('noobserver', properties?.noobserver); element.toggleAttribute('noobserver', properties?.noobserver ?? true);
properties?.width && el.setAttribute('width', properties?.width.toString()); properties?.width && element.setAttribute('width', properties?.width.toString());
properties?.height && el.setAttribute('height', properties?.height.toString()); properties?.height && element.setAttribute('height', properties?.height.toString());
properties?.flip && el.setAttribute('flip', properties?.flip.toString()); properties?.flip && element.setAttribute('flip', properties?.flip.toString());
properties?.rotate && el.setAttribute('rotate', properties?.rotate.toString()); properties?.rotate && element.setAttribute('rotate', properties?.rotate.toString());
if(properties?.class) if(properties?.class)
{ {
el.setAttribute('class', mergeClasses(properties.class)); element.setAttribute('class', mergeClasses(properties.class));
} }
if(properties?.style) if(properties?.style)
{ {
if(typeof properties.style === 'string') if(typeof properties.style === 'string')
{ {
el.setAttribute('style', properties.style); element.setAttribute('style', properties.style);
} }
else else
for(const [k, v] of Object.entries(properties.style)) if(v !== undefined) el.attributeStyleMap.set(k, v); for(const [k, v] of Object.entries(properties.style)) if(v !== undefined) element.attributeStyleMap.set(k, v);
} }
return el; return element;
} }
export function mergeClasses(classes: Class): string export function mergeClasses(classes: Class): string

File diff suppressed because it is too large Load Diff

View File

@@ -17,38 +17,44 @@ export interface FollowerProperties extends FloatingProperties
blur?: () => void; blur?: () => void;
priority?: boolean; priority?: boolean;
} }
export type FloatState = 'shown' | 'showing' | 'hidden' | 'hiding' | 'pinned';
export interface PopperProperties extends FloatingProperties export interface PopperProperties extends FloatingProperties
{ {
content?: NodeChildren | (() => NodeChildren); content?: NodeChildren | (() => NodeChildren);
delay?: number; delay?: number;
events?: {
onShow?: () => boolean | void; show: Array<keyof HTMLElementEventMap>;
onHide?: () => boolean | void; hide: Array<keyof HTMLElementEventMap>;
onshow?: (state: FloatState) => boolean;
onhide?: (state: FloatState) => boolean;
};
} }
export interface ModalProperties export interface ModalProperties
{ {
priority?: boolean; priority?: boolean;
closeWhenOutside?: boolean; closeWhenOutside?: boolean;
onClose?: () => boolean | void;
} }
let teleport: HTMLDivElement; let teleport: HTMLDivElement;
export function init() export function init()
{ {
teleport = dom('div', { attributes: { id: 'popper-container' }, class: 'absolute top-0 left-0' }); teleport = dom('div', { attributes: { id: 'popper-container' }, class: 'absolute top-0 left-0 z-40' });
document.body.appendChild(teleport); document.body.appendChild(teleport);
} }
export function popper(container: HTMLElement, properties?: PopperProperties): HTMLElement export function popper(container: HTMLElement, properties?: PopperProperties)
{ {
let shown = false, timeout: Timer; let state: FloatState = 'hidden', manualStop = false, timeout: Timer;
const arrow = svg('svg', { class: 'absolute fill-light-35 dark:fill-dark-35', attributes: { width: "12", height: "8", viewBox: "0 0 20 10" } }, [svg('polygon', { attributes: { points: "0,0 20,0 10,10" } })]); const arrow = svg('svg', { class: ' group-data-[pinned]:hidden absolute fill-light-35 dark:fill-dark-35', attributes: { width: "12", height: "8", viewBox: "0 0 20 10" } }, [svg('polygon', { attributes: { points: "0,0 20,0 10,10" } })]);
const content = dom('div', { class: ['fixed hidden', properties?.class], style: properties?.style, attributes: { 'data-state': 'closed' } }); const content = dom('div', { class: properties?.class, style: properties?.style });
const floater = dom('div', { class: 'fixed hidden group', attributes: { 'data-state': 'closed' } }, [ content, properties?.arrow ? arrow : undefined ]);
const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport'; const rect = properties?.viewport?.getBoundingClientRect() ?? 'viewport';
function update() function update()
{ {
FloatingUI.computePosition(container, content, { FloatingUI.computePosition(container, floater, {
placement: properties?.placement, placement: properties?.placement,
strategy: 'fixed', strategy: 'fixed',
middleware: [ middleware: [
@@ -58,14 +64,14 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
FloatingUI.flip({ rootBoundary: rect }), FloatingUI.flip({ rootBoundary: rect }),
properties?.cover && properties?.cover !== 'none' && FloatingUI.size({ rootBoundary: rect, apply: ({ availableWidth, availableHeight }) => { properties?.cover && properties?.cover !== 'none' && FloatingUI.size({ rootBoundary: rect, apply: ({ availableWidth, availableHeight }) => {
if(properties?.cover === 'width' || properties?.cover === 'all') if(properties?.cover === 'width' || properties?.cover === 'all')
content.style.width = `${availableWidth}px`; floater.style.maxWidth = `${availableWidth}px`;
if(properties?.cover === 'height' || properties?.cover === 'all') if(properties?.cover === 'height' || properties?.cover === 'all')
content.style.height = `${availableHeight}px`; floater.style.maxHeight = `${availableHeight}px`;
} }), } }),
properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined, properties?.offset && properties?.arrow ? FloatingUI.arrow({ element: arrow, padding: 8 }) : undefined,
] ]
}).then(({ x, y, placement, middlewareData }) => { }).then(({ x, y, placement, middlewareData }) => {
Object.assign(content.style, { Object.assign(floater.style, {
left: `${x}px`, left: `${x}px`,
top: `${y}px`, top: `${y}px`,
visibility: middlewareData.hide?.referenceHidden || middlewareData.hide?.escaped ? 'hidden' : 'visible', visibility: middlewareData.hide?.referenceHidden || middlewareData.hide?.escaped ? 'hidden' : 'visible',
@@ -73,7 +79,7 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
const side = placement.split('-')[0] as FloatingUI.Side; const side = placement.split('-')[0] as FloatingUI.Side;
content.setAttribute('data-side', side); floater.setAttribute('data-side', side);
if(middlewareData.arrow) if(middlewareData.arrow)
{ {
@@ -84,53 +90,55 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
right: 'left', right: 'left',
bottom: 'top', bottom: 'top',
left: 'right', left: 'right',
}[side]!; }[side]!;
const rotation = { const rotation = {
top: "0", top: "0",
bottom: "180", bottom: "180",
left: "270", left: "270",
right: "90" right: "90"
}[side]!; }[side]!;
Object.assign(arrow.style, { Object.assign(arrow.style, {
left: arrowX != null ? `${arrowX}px` : '', left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '', top: arrowY != null ? `${arrowY}px` : '',
right: '', right: '',
bottom: '', bottom: '',
[staticSide]: `-8px`, [staticSide]: `-7px`,
transform: `rotate(${rotation}deg)`, transform: `rotate(${rotation}deg)`,
}); });
} }
}); });
} }
let stop: () => void | undefined; let _stop: () => void | undefined, empty = true;
function show() function show()
{ {
if(shown || !properties?.onShow || properties?.onShow() !== false) if(state !== 'shown' && state !== 'showing' && state !== 'pinned' && (!properties?.events?.onshow || properties?.events?.onshow(state) !== false))
{ {
if(typeof properties?.content === 'function') if(typeof properties?.content === 'function')
{
properties.content = properties.content(); properties.content = properties.content();
}
if(content.children.length === 0 && (properties?.content && properties.content.length > 0 || properties?.arrow)) if(properties?.content && empty)
{ {
content.replaceChildren(...(properties!.content as Node[]), arrow); content.replaceChildren(...properties!.content.filter(e => !!e));
empty = false;
} }
clearTimeout(timeout); clearTimeout(timeout);
state = 'showing';
timeout = setTimeout(() => { timeout = setTimeout(() => {
if(!shown) if(state !== 'shown')
{ {
teleport!.appendChild(content); teleport!.appendChild(floater);
content.setAttribute('data-state', 'open'); floater.setAttribute('data-state', 'open');
content.classList.toggle('hidden', false); floater.classList.toggle('hidden', false);
update(); update();
stop = FloatingUI.autoUpdate(container, content, update, { _stop && _stop();
_stop = FloatingUI.autoUpdate(container, floater, update, {
animationFrame: true, animationFrame: true,
layoutShift: false, layoutShift: false,
elementResize: false, elementResize: false,
@@ -138,41 +146,93 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
ancestorResize: false, ancestorResize: false,
}); });
} }
shown = true; state = 'shown';
}, properties?.delay ?? 0); }, properties?.delay ?? 0);
} }
} }
function hide() function hide()
{ {
if(!properties?.onHide || properties?.onHide() !== false) if(state !== 'hiding' && state !== 'pinned' && (!properties?.events?.onhide || properties?.events?.onhide(state) !== false))
{ {
clearTimeout(timeout); clearTimeout(timeout);
state = 'hiding';
timeout = setTimeout(() => { timeout = setTimeout(() => {
content.remove(); floater.remove();
stop && stop(); _stop && _stop();
shown = false; floater.setAttribute('data-state', 'closed');
}, shown ? properties?.delay ?? 0 : 0); floater.classList.toggle('hidden', true);
state = 'hidden';
}, properties?.delay ?? 0);
} }
} }
function link(element: HTMLElement) { function start()
Object.entries({ {
'mouseenter': show, state = 'hidden';
'mouseleave': hide, floater.toggleAttribute('data-pinned', false);
'focus': show, update();
'blur': hide, _stop && _stop();
} as Record<keyof HTMLElementEventMap, () => void>).forEach(([event, listener]) => { _stop = FloatingUI.autoUpdate(container, floater, update, {
element.addEventListener(event, listener); animationFrame: true,
layoutShift: false,
elementResize: false,
ancestorScroll: false,
ancestorResize: false,
}); });
} }
link(container); function stop()
link(content); {
state = 'pinned';
floater.toggleAttribute('data-pinned', true);
_stop && _stop();
clearTimeout(timeout);
}
return container; function link(element: HTMLElement) {
(properties?.events?.show ?? ['mouseenter', 'mousemove', 'focus']).forEach((e: keyof HTMLElementEventMap) => element.addEventListener(e, show));
(properties?.events?.hide ?? ['mouseleave', 'blur']).forEach((e: keyof HTMLElementEventMap) => element.addEventListener(e, hide));
}
link(container);
link(floater);
return { container, content: floater, stop, start, show: () => {
if(typeof properties?.content === 'function')
properties.content = properties.content();
if(properties?.content && empty)
{
content.replaceChildren(...properties!.content.filter(e => !!e));
empty = false;
}
if(state !== 'shown')
{
teleport!.appendChild(floater);
floater.setAttribute('data-state', 'open');
floater.classList.toggle('hidden', false);
update();
}
state = 'shown';
}, hide: () => {
floater.remove();
_stop && _stop();
floater.setAttribute('data-state', 'closed');
floater.classList.toggle('hidden', true);
manualStop = false;
floater.toggleAttribute('data-pinned', false);
state = 'hidden';
} };
} }
export function followermenu(target: FloatingUI.ReferenceElement, content: NodeChildren, properties?: FollowerProperties) export function followermenu(target: FloatingUI.ReferenceElement, content: NodeChildren, properties?: FollowerProperties)
{ {
@@ -288,22 +348,24 @@ export function tooltip(container: HTMLElement, txt: string | Text, placement: F
arrow: true, arrow: true,
offset: 8, offset: 8,
delay: delay, delay: delay,
content: [ typeof txt === 'string' ? text(txt) : txt ], content: () => [ typeof txt === 'string' ? text(txt) : txt ],
placement: placement, placement: placement,
class: "fixed hidden TooltipContent border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50 max-w-96" class: "border border-light-30 dark:border-dark-30 px-2 py-1 bg-light-10 dark:bg-dark-10 text-light-70 dark:text-dark-70 z-50 max-w-96"
}); }).container;
} }
export function fullblocker(content: NodeChildren, properties?: ModalProperties) export function fullblocker(content: NodeChildren, properties?: ModalProperties)
{ {
const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (() => _modal.remove()) : undefined } }); if(!content)
return { close: () => {} };
const close = () => (!properties?.onClose || properties.onClose() !== false) && _modal.remove();
const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (close) : undefined } });
const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, ...content]); const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, ...content]);
teleport.appendChild(_modal); teleport.appendChild(_modal);
return { return { close };
close: () => _modal.remove(),
}
} }
export function modal(content: NodeChildren, properties?: ModalProperties) export function modal(content: NodeChildren, properties?: ModalProperties)
{ {

View File

@@ -4,13 +4,13 @@ import prose, { a, blockquote, tag, h1, h2, h3, h4, h5, hr, li, small, table, td
import { heading } from "hast-util-heading"; import { heading } from "hast-util-heading";
import { headingRank } from "hast-util-heading-rank"; import { headingRank } from "hast-util-heading-rank";
import { parseId } from "#shared/general.util"; import { parseId } from "#shared/general.util";
import { async, loading } from "#shared/components.util"; import { async } from "#shared/components.util";
export function renderMarkdown(markdown: Root, proses: Record<string, Prose>): HTMLDivElement export function renderMarkdown(markdown: Root, proses: Record<string, Prose>): HTMLDivElement
{ {
return dom('div', {}, markdown.children.map(e => renderContent(e, proses))); return dom('div', {}, markdown.children.map(e => renderContent(e, proses)));
} }
export function renderText(markdown: string): string export function renderMDAsText(markdown: string): string
{ {
return useMarkdown().text(markdown); return useMarkdown().text(markdown);
} }
@@ -43,9 +43,9 @@ export interface MDProperties
style?: string | Record<string, string>; style?: string | Record<string, string>;
tags?: Record<string, Prose>; tags?: Record<string, Prose>;
} }
export default function(content: string, filter?: string, properties?: MDProperties): HTMLElement export function markdownReference(content: string, filter?: string, properties?: MDProperties)
{ {
return async('large', useMarkdown().parse(content).then(data => { const state = async('large', useMarkdown().parse(content).then(data => {
if(filter) if(filter)
{ {
const start = data?.children.findIndex(e => heading(e) && parseId(e.properties.id as string | undefined) === filter) ?? -1; const start = data?.children.findIndex(e => heading(e) && parseId(e.properties.id as string | undefined) === filter) ?? -1;
@@ -53,11 +53,11 @@ export default function(content: string, filter?: string, properties?: MDPropert
if(start !== -1) if(start !== -1)
{ {
let end = start; let end = start;
const rank = headingRank(data.children[start])!; const rank = headingRank(data.children[start]!)!;
while(end < data.children.length) while(end < data.children.length)
{ {
end++; end++;
if(heading(data.children[end]) && headingRank(data.children[end])! <= rank) if(heading(data.children[end]) && headingRank(data.children[end]!)! <= rank)
break; break;
} }
data = { ...data, children: data.children.slice(start, end) }; data = { ...data, children: data.children.slice(start, end) };
@@ -70,4 +70,9 @@ export default function(content: string, filter?: string, properties?: MDPropert
return el; return el;
})); }));
return state;
}
export default function(content: string, filter?: string, properties?: MDProperties): HTMLElement
{
return markdownReference(content, filter, properties).current;
} }

View File

@@ -1,11 +1,11 @@
import { dom, icon, type NodeChildren, type Node, div } from "#shared/dom.util"; import { dom, icon, type NodeChildren, type Node, div, type Class } from "#shared/dom.util";
import { parseURL } from 'ufo'; import { parseURL } from 'ufo';
import render from "#shared/markdown.util"; import render from "#shared/markdown.util";
import { popper } from "#shared/floating.util";
import { Canvas } from "#shared/canvas.util"; import { Canvas } from "#shared/canvas.util";
import { Content, iconByType, type LocalContent } from "#shared/content.util"; import { Content, iconByType, type LocalContent } from "#shared/content.util";
import { parsePath, unifySlug } from "#shared/general.util"; import { unifySlug } from "#shared/general.util";
import { async, loading } from "./components.util"; import { async, floater } from "./components.util";
import type { FloatState } from "./floating.util";
export type CustomProse = (properties: any, children: NodeChildren) => Node; export type CustomProse = (properties: any, children: NodeChildren) => Node;
@@ -20,7 +20,7 @@ export const a: Prose = {
const link = overview ? { name: 'explore-path', params: { path: overview.path }, hash: hash } : href, nav = router.resolve(link); const link = overview ? { name: 'explore-path', params: { path: overview.path }, hash: hash } : href, nav = router.resolve(link);
const el = dom('a', { class: ['text-accent-blue inline-flex items-center', properties?.class], attributes: { href: nav.href }, listeners: { const element = dom('a', { class: ['text-accent-blue inline-flex items-center', properties?.class], attributes: { href: nav.href }, listeners: {
'click': (e) => { 'click': (e) => {
e.preventDefault(); e.preventDefault();
router.push(link); router.push(link);
@@ -32,84 +32,55 @@ export const a: Prose = {
]) ])
]); ]);
if(!!overview) return !!overview ? floater(element, () => [async('large', Content.getContent(overview.id).then((_content) => {
{ if(_content?.type === 'markdown')
popper(el, { {
arrow: true, return render((_content as LocalContent<'markdown'>).content ?? '', hash.length > 0 ? hash.substring(1) : undefined, { class: 'w-full max-h-full overflow-auto py-4 px-6' });
delay: 150, }
offset: 12, if(_content?.type === 'canvas')
cover: "height", {
placement: 'bottom-start', const canvas = new Canvas((_content as LocalContent<'canvas'>).content);
class: 'data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 data-[state=open]:transition-transform text-light-100 dark:text-dark-100 min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full overflow-auto z-[45]', queueMicrotask(() => canvas.mount());
content: () => { return dom('div', { class: 'w-[600px] h-[600px] group-data-[pinned]:h-full group-data-[pinned]:w-full h-[600px] relative w-[600px] relative' }, [canvas.container]);
return [async('large', Content.getContent(overview.id).then((_content) => { }
if(_content?.type === 'markdown') return div('');
{ })).current], { position: 'bottom-start', pinned: false, title: properties?.label, href: nav.href }) : element;
return render((_content as LocalContent<'markdown'>).content ?? '', hash.length > 0 ? hash.substring(1) : undefined, { class: 'w-full max-h-full overflow-auto py-4 px-6' });
}
if(_content?.type === 'canvas')
{
const canvas = new Canvas((_content as LocalContent<'canvas'>).content);
queueMicrotask(() => canvas.mount());
return dom('div', { class: 'w-[600px] h-[600px] relative' }, [canvas.container]);
}
return div('');
}))];
},
viewport: document.getElementById('mainContainer') ?? undefined
});
}
return el;
} }
} }
export const preview: Prose = { export const preview: Prose = {
custom(properties, children) { custom(properties: { href: string, class?: Class, label: string }, children) {
const href = properties.href as string; const href = properties.href as string;
const { hash, pathname } = parseURL(href); const { hash, pathname } = parseURL(href);
const router = useRouter(); const router = useRouter();
const overview = Content.getFromPath(pathname === '' && hash.length > 0 ? unifySlug(router.currentRoute.value.params.path ?? '') : pathname); const overview = Content.getFromPath(pathname === '' && hash.length > 0 ? unifySlug(router.currentRoute.value.params.path ?? '') : pathname);
const el = dom('span', { class: ['cursor-pointer text-accent-blue inline-flex items-center', properties?.class] }, [ const element = dom('span', { class: ['cursor-pointer text-accent-blue inline-flex items-center', properties?.class] }, [
...(children ?? []), ...(children ?? []),
overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined
]); ]);
const magicKeys = useMagicKeys();
if(!!overview) return !!overview ? floater(element, () => [async('large', Content.getContent(overview.id).then((_content) => {
{ if(_content?.type === 'markdown')
const magicKeys = useMagicKeys(); {
popper(el, { return render((_content as LocalContent<'markdown'>).content ?? '', hash.length > 0 ? hash.substring(1) : undefined, { class: 'w-full max-h-full overflow-auto py-4 px-6' });
arrow: true, }
delay: 150, if(_content?.type === 'canvas')
offset: 12, {
cover: "height", const canvas = new Canvas((_content as LocalContent<'canvas'>).content);
placement: 'bottom-start', queueMicrotask(() => canvas.mount());
class: 'data-[side=bottom]:animate-slideUpAndFade data-[side=right]:animate-slideLeftAndFade data-[side=left]:animate-slideRightAndFade data-[side=top]:animate-slideDownAndFade w-[300px] bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 data-[state=open]:transition-transform text-light-100 dark:text-dark-100 min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-full z-[45]', return dom('div', { class: 'w-[600px] h-[600px] group-data-[pinned]:h-full group-data-[pinned]:w-full h-[600px] relative w-[600px] relative' }, [canvas.container]);
content: () => { }
return [async('large', Content.getContent(overview.id).then((_content) => { return div();
if(_content?.type === 'markdown') })).current], { position: 'bottom-start', pinned: false,
{ events: {
return render((_content as LocalContent<'markdown'>).content ?? '', hash.length > 0 ? hash.substring(1) : undefined, { class: 'w-full max-h-full overflow-auto py-4 px-6' }); show: ['mouseenter', 'mousemove'],
} hide: ['mouseleave'],
if(_content?.type === 'canvas') onshow(state: FloatState) {
{ return state === 'shown' || state === 'hiding' || magicKeys.current.has('control') || magicKeys.current.has('meta');
const canvas = new Canvas((_content as LocalContent<'canvas'>).content); }
queueMicrotask(() => canvas.mount()); }, title: properties?.label, href: { name: 'explore-path', params: { path: overview.path }, hash: hash } }) : element;
return dom('div', { class: 'w-[600px] h-[600px] relative' }, [canvas.container]);
}
return div('');
}))];
},
onShow() {
if(!magicKeys.current.has('control') || magicKeys.current.has('meta'))
return false;
},
});
}
return el;
} }
} }
export const callout: Prose = { export const callout: Prose = {

46
types/character.d.ts vendored
View File

@@ -1,5 +1,5 @@
import type { MAIN_STATS, ABILITIES, LEVELS, TRAINING_LEVELS, SPELL_TYPES, CATEGORIES, SPELL_ELEMENTS, ALIGNMENTS, RESISTANCES } from "#shared/character.util"; import type { MAIN_STATS, ABILITIES, LEVELS, TRAINING_LEVELS, SPELL_TYPES, CATEGORIES, SPELL_ELEMENTS, ALIGNMENTS, RESISTANCES, DAMAGE_TYPES, WEAPON_TYPES } from "#shared/character.util";
import type { Localized } from "#shared/general"; import type { Localized } from "../types/general";
export type MainStat = typeof MAIN_STATS[number]; export type MainStat = typeof MAIN_STATS[number];
export type Ability = typeof ABILITIES[number]; export type Ability = typeof ABILITIES[number];
@@ -10,6 +10,8 @@ export type Category = typeof CATEGORIES[number];
export type SpellElement = typeof SPELL_ELEMENTS[number]; export type SpellElement = typeof SPELL_ELEMENTS[number];
export type Alignment = typeof ALIGNMENTS[number]; export type Alignment = typeof ALIGNMENTS[number];
export type Resistance = typeof RESISTANCES[number]; export type Resistance = typeof RESISTANCES[number];
export type DamageType = typeof DAMAGE_TYPES[number];
export type WeaponType = typeof WEAPON_TYPES[number];
export type FeatureID = string; export type FeatureID = string;
export type i18nID = string; export type i18nID = string;
@@ -67,7 +69,11 @@ export type CharacterConfig = {
features: Record<FeatureID, Feature>; features: Record<FeatureID, Feature>;
enchantments: Record<string, EnchantementConfig>; //TODO enchantments: Record<string, EnchantementConfig>; //TODO
items: Record<string, ItemConfig>; items: Record<string, ItemConfig>;
lists: Record<string, { id: string, config: ListConfig, values: Record<string, any> }>; sickness: Record<string, { id: string, name: string, description: string, effect: FeatureID[] }>;
action: Record<string, { id: string, name: string, description: string, cost: number }>;
reaction: Record<string, { id: string, name: string, description: string, cost: number }>;
freeaction: Record<string, { id: string, name: string, description: string }>;
passive: Record<string, { id: string, name: string, description: string }>;
texts: Record<i18nID, Localized>; texts: Record<i18nID, Localized>;
}; };
export type EnchantementConfig = { export type EnchantementConfig = {
@@ -78,36 +84,38 @@ export type EnchantementConfig = {
export type ItemConfig = CommonItemConfig & (ArmorConfig | WeaponConfig | WondrousConfig | MundaneConfig); export type ItemConfig = CommonItemConfig & (ArmorConfig | WeaponConfig | WondrousConfig | MundaneConfig);
type CommonItemConfig = { type CommonItemConfig = {
id: string; id: string;
name: string; //TODO -> TextID
description: i18nID;
rarity: 'common' | 'uncommon' | 'rare' | 'legendary'; rarity: 'common' | 'uncommon' | 'rare' | 'legendary';
weight?: number; //Optionnal but highly recommended weight?: number; //Optionnal but highly recommended
price?: number; //Optionnal but highly recommended price?: number; //Optionnal but highly recommended
power?: number; //Optionnal as most mundane items should not receive enchantments (potions, herbal heals, etc...) capacity?: number; //Optionnal as most mundane items should not receive enchantments (potions, herbal heals, etc...)
powercost?: number; //Optionnal
charge?: number //Max amount of charges charge?: number //Max amount of charges
enchantments?: string[]; //Enchantment ID
effects?: Array<FeatureValue | FeatureEquipment | FeatureList>;
equippable: boolean; equippable: boolean;
consummable: boolean;
} }
type ArmorConfig = { type ArmorConfig = {
category: 'armor'; category: 'armor';
name: string; //TODO -> TextID
description: i18nID;
health: number; health: number;
type: 'light' | 'medium' | 'heavy';
absorb: { static: number, percent: number }; absorb: { static: number, percent: number };
}; };
type WeaponConfig = { type WeaponConfig = {
category: 'weapon'; category: 'weapon';
name: string; //TODO -> TextID type: Array<WeaponType>;
description: i18nID; damage: {
damage: string; //Dice formula value: string; //Dice formula
type: DamageType;
};
}; };
type WondrousConfig = { type WondrousConfig = {
category: 'wondrous'; category: 'wondrous';
name: string; //TODO -> TextID
description: i18nID;
effect: FeatureItem[];
}; };
type MundaneConfig = { type MundaneConfig = {
category: 'mundane'; category: 'mundane';
name: string; //TODO -> TextID
description: i18nID;
}; };
export type SpellConfig = { export type SpellConfig = {
id: string; id: string;
@@ -117,8 +125,9 @@ export type SpellConfig = {
cost: number; cost: number;
speed: "action" | "reaction" | number; speed: "action" | "reaction" | number;
elements: Array<SpellElement>; elements: Array<SpellElement>;
effect: string; //TODO -> TextID description: string; //TODO -> TextID
concentration: boolean; concentration: boolean;
range: 'personnal' | number;
tags?: string[]; tags?: string[];
}; };
export type RaceConfig = { export type RaceConfig = {
@@ -151,7 +160,7 @@ export type FeatureEquipment = {
id: FeatureID; id: FeatureID;
category: "value"; category: "value";
operation: "add" | "set" | "min"; operation: "add" | "set" | "min";
property: 'weapon/damage' | 'armor/health' | 'armor/absorb/flat' | 'armor/absorb/percent'; property: 'weapon/damage/value' | 'armor/health' | 'armor/absorb/flat' | 'armor/absorb/percent';
value: number | `modifier/${MainStat}` | false; value: number | `modifier/${MainStat}` | false;
} }
export type FeatureList = { export type FeatureList = {
@@ -159,8 +168,7 @@ export type FeatureList = {
category: "list"; category: "list";
list: "spells" | "sickness" | "action" | "reaction" | "freeaction" | "passive"; list: "spells" | "sickness" | "action" | "reaction" | "freeaction" | "passive";
action: "add" | "remove"; action: "add" | "remove";
item: string | i18nID; item: string;
extra?: any;
}; };
export type FeatureChoice = { export type FeatureChoice = {
id: FeatureID; id: FeatureID;
@@ -175,7 +183,7 @@ export type FeatureChoice = {
export type FeatureItem = FeatureValue | FeatureList | FeatureChoice; export type FeatureItem = FeatureValue | FeatureList | FeatureChoice;
export type Feature = { export type Feature = {
id: FeatureID; id: FeatureID;
description: i18nID; description: string; //TODO -> TextID
effect: FeatureItem[]; effect: FeatureItem[];
}; };