12 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
48 changed files with 13151 additions and 936 deletions

12
app.vue
View File

@@ -1,13 +1,11 @@
<template>
<div class="text-light-100 dark:text-dark-100 flex bg-light-0 dark:bg-dark-0 h-screen overflow-hidden">
<NuxtRouteAnnouncer/>
<TooltipProvider>
<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">
<NuxtPage />
</div>
</NuxtLayout>
</TooltipProvider>
<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">
<NuxtPage />
</div>
</NuxtLayout>
</div>
</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 * as schema from '../db/schema';
let instance: BunSQLiteDatabase<typeof schema>;
let instance: BunSQLiteDatabase<typeof schema> & {
$client: Database;
};
export default function useDatabase()
{
if(!instance)
@@ -13,6 +15,7 @@ export default function useDatabase()
instance.run("PRAGMA journal_mode = WAL;");
instance.run("PRAGMA foreign_keys = true;");
instance.run("PRAGMA optimize=0x10002;");
}
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 { 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", {
id: int().primaryKey({ autoIncrement: true }),
@@ -55,9 +54,10 @@ export const characterTable = table("character", {
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
people: text().notNull(),
level: int().notNull().default(1),
variables: text({ mode: 'json' }).notNull().default('{"health": 0,"mana": 0,"spells": [],"equipment": [],"exhaustion": 0,"sickness": [],"poisons": []}'),
variables: text({ mode: 'json' }).notNull().default('{"health": 0,"mana": 0,"spells": [],"items": [],"exhaustion": 0,"sickness": [],"poisons": []}'),
aspect: int(),
notes: text(),
public_notes: text(),
private_notes: text(),
visibility: text({ enum: ['private', 'public'] }).notNull().default('private'),
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,
"tag": "0016_wild_the_anarchist",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1760531331328,
"tag": "0017_workable_scrambler",
"breakpoints": true
}
]
}

View File

@@ -1,87 +1,65 @@
<template>
<CollapsibleRoot class="flex flex-1 flex-col" v-model:open="open">
<div class="z-30 flex w-full items-center justify-between border-b border-light-35 dark:border-dark-35 px-2">
<div class="flex items-center px-2 gap-4">
<CollapsibleTrigger asChild>
<Button icon class="!bg-transparent group md:hidden">
<Icon class="group-data-[state=open]:hidden" icon="radix-icons:hamburger-menu" />
<Icon class="group-data-[state=closed]:hidden" icon="radix-icons:cross-1" />
</Button>
</CollapsibleTrigger>
<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 }">
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
<Avatar src="/logo.light.svg" class="block dark:hidden" />
<span class="text-xl max-md:hidden">d[any]</span>
</NuxtLink>
<div class="flex flex-row w-full max-w-full h-full max-h-full" style="--sidebar-width: 300px">
<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">
<NuxtLink class="flex flex-row items-center justify-center group gap-2 my-2" aria-label="Accueil" :to="{ name: 'index', force: true }">
<Avatar src="/logo.dark.svg" class="dark:block hidden" />
<Avatar src="/logo.light.svg" class="block dark:hidden" />
<span class="text-xl font-semibold group-hover:text-light-70 dark:group-hover:text-dark-70">d[any]</span>
</NuxtLink>
<div class="flex-1 px-2 max-w-full max-h-full overflow-y-auto overflow-x-hidden" ref="treeParent"></div>
<div class="flex flex-col my-4 items-center justify-center gap-1 text-xs text-light-60 dark:text-dark-60">
<NuxtLink class="hover:underline" :to="{ name: 'legal' }">Mentions légales</NuxtLink>
<NuxtLink class="hover:underline" :to="{ name: 'usage' }">Conditions d'utilisations</NuxtLink>
Copyright Peaceultime - 2025
</div>
<NavigationMenuRoot class="relative">
<NavigationMenuList class="flex items-center gap-8 max-md:hidden">
<NavigationMenuItem>
<NavigationMenuTrigger>
<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>
</NavigationMenuTrigger>
<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">
<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>
<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>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
<div class="absolute top-full left-0 flex w-full justify-center">
<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)]" />
</div>
</NavigationMenuRoot>
<div class="flex items-center px-2 gap-4">
<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>
<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 class="flex flex-col flex-1 h-full w-[calc(100vw-var(--sidebar-width))]">
<div class="flex flex-row border-b border-light-30 dark:border-dark-30 justify-between px-8">
<div class="flex flex-row gap-16 items-center">
<NavigationMenuRoot class="relative">
<NavigationMenuList class="flex items-center gap-8 max-md:hidden">
<NavigationMenuItem>
<NavigationMenuTrigger>
<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>
</NavigationMenuTrigger>
<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">
<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>
<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>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
<div class="absolute top-full left-0 flex w-full justify-center">
<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)]" />
</div>
</div>
<div class="xl:px-12 px-6 pt-4 pb-2 text-center text-xs text-light-60 dark:text-dark-60">
<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>
</NavigationMenuRoot>
<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>
</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>
</div>
</CollapsibleRoot>
</div>
</template>
<script setup lang="ts">
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 { 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 { popper, tooltip } from '#shared/floating.util';
import { tooltip } from '#shared/floating.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 { loggedIn, user, clear } = useUserSession();
const { loggedIn, user } = useUserSession();
const { fetch } = useContent();
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, 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 }),
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,
])]);
], { 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);
(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');
@@ -119,9 +97,7 @@ watch(route, () => {
onMounted(() => {
if(treeParent.value)
{
treeParent.value.appendChild(tree.container);
}
})
onUnmounted(() => {
unmount();

View File

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

View File

@@ -1,17 +1,8 @@
<script setup lang="ts">
import characterConfig from '#shared/character-config.json';
import { Icon } from '@iconify/vue/dist/iconify.js';
import PreviewA from '~/components/prose/PreviewA.vue';
import { clamp, unifySlug } from '#shared/general.util';
import type { CompiledCharacter, SpellConfig } from '~/types/character';
import { unifySlug } from '#shared/general.util';
import type { CharacterConfig } from '~/types/character';
import { abilityTexts, CharacterCompiler, CharacterSheet, defaultCharacter, elementTexts, spellTypeTexts } 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';
import { CharacterSheet } from '#shared/character.util';
/*
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
@@ -35,180 +26,12 @@ onMounted(() => {
if(container.value && id)
{
const character = new CharacterSheet(id, user);
container.value.appendChild(character.container);
container.value.replaceWith(character.container);
}
});
});
</script>
<template>
<div 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> -->
<div class="flex flex-1 w-full h-full items-start justify-center" ref="container"></div>
</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">
<Loading size="large" />
</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">
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters">
<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-row gap-8 ps-4 items-center">
<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 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>
<template v-else-if="status === 'success'">
<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">
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters">
<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-row gap-8 ps-4 items-center">
<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>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</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>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</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>
<span>Erreur de chargement</span>
<span>{{ error?.message }}</span>

View File

@@ -13,24 +13,34 @@ const config = characterConfig as CharacterConfig;
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
<Loading size="large" />
</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">
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters">
<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">
<div class="flex flex-row gap-8 ps-4 items-center">
<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>
<template v-else-if="status === 'success'">
<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">
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="character of characters">
<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">
<div class="flex flex-row gap-8 ps-4 items-center">
<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>
<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>
</NuxtLink>
</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>
<span>Erreur de chargement</span>
<span>{{ error?.message }}</span>

View File

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

View File

@@ -3,8 +3,8 @@
<Title>d[any] - Mentions légales</Title>
</Head>
<div class="flex flex-col max-w-[1200px] p-16">
<ProseH3>Mentions Légales</ProseH3>
<ProseH4>Collecte et Traitement des Données Personnelles</ProseH4>
<h3 class="text-xl font-bold">Mentions Légales</h3>
<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
le site dans un but de collecte statistiques.<br />
@@ -12,21 +12,21 @@
suppression de vos données personnelles. <br />
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
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 />
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 />
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
sur la propriété intellectuelle. Toute reproduction ou utilisation de ces contenus sans autorisation préalable
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>
</Head>
<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">
<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>

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center 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>
<ProseH4>Reinitialisation de mon mot de passe</ProseH4>
<h4 class="text-xl font-bold">Reinitialisation de mon mot de passe</h4>
</div>
<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"/>

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center 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>
<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>
<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 }"/>

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center 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>
<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>
<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"/>

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center 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>
<ProseH4>Connexion</ProseH4>
<h4 class="text-xl font-bold">Connexion</h4>
</div>
<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"/>

View File

@@ -38,8 +38,8 @@ async function deleteUser()
<div class="flex gap-4">
<Avatar icon="radix-icons:person" :src="`/users/${user?.id}.medium.jpg`" class="w-32 h-32" />
<div class="flex flex-col items-start">
<ProseH5>{{ user.username }}</ProseH5>
<ProseH5>{{ user.email }}</ProseH5>
<h4 class="text-xl font-bold">{{ user.username }}</h4>
<h4 class="text-xl font-bold">{{ user.email }}</h4>
</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"

View File

@@ -5,7 +5,7 @@
<div class="flex flex-1 flex-col justify-center 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>
<ProseH4>Inscription</ProseH4>
<h4 class="text-xl font-bold">Inscription</h4>
</div>
<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"/>
@@ -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>
</div>
<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>
<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>
@@ -50,6 +51,7 @@ const checkedLower = computed(() => state.password.toUpperCase() !== state.passw
const checkedUpper = computed(() => state.password.toLowerCase() !== state.password);
const checkedDigit = computed(() => /[0-9]/.test(state.password));
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', {
body: state,
@@ -57,7 +59,7 @@ const { data: result, status, error, refresh } = await useFetch('/api/auth/regis
method: 'POST',
watch: false,
ignoreResponseError: true,
})
});
async function submit()
{
@@ -69,6 +71,8 @@ async function submit()
return Toaster.add({ content: 'Veuillez saisir un mot de passe', timer: true, duration: 10000 });
if(state.password !== confirmPassword.value)
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);

View File

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

View File

@@ -16,9 +16,9 @@ export default defineEventHandler(async (e) => {
let where: ((character: typeof characterTable._.config.columns, sql: Operators) => SQL | undefined) | undefined = undefined;
const db = useDatabase();
const session = await getUserSession(e);
if(visibility === "own")
{
const session = await getUserSession(e);
if(!session.user)
{
setResponseStatus(e, 401);
@@ -33,7 +33,6 @@ export default defineEventHandler(async (e) => {
}
else if(visibility === 'admin')
{
const session = await getUserSession(e);
if(!session.user)
{
setResponseStatus(e, 401);
@@ -73,7 +72,7 @@ export default defineEventHandler(async (e) => {
people: character.people,
level: character.level,
aspect: character.aspect,
notes: character.notes,
notes: { public: character.public_notes, private: session.user?.id === character.owner ? character.private_notes : undefined },
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>>>),

View File

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

View File

@@ -43,7 +43,7 @@ export default defineEventHandler(async (e) => {
people: character.people,
level: character.level,
aspect: character.aspect,
notes: character.notes,
notes: { public: character.public_notes, private: session.user?.id === character.owner ? character.private_notes : undefined },
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>>>),

View File

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

View File

@@ -0,0 +1,45 @@
import { eq } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable } from '~/db/schema';
import { CharacterNotesValidation } from '#shared/character.util';
export default defineEventHandler(async (e) => {
const id = getRouterParam(e, "id");
if(!id)
{
setResponseStatus(e, 400);
return;
}
const body = await readValidatedBody(e, CharacterNotesValidation.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({
public_notes: body.data.public,
private_notes: body.data.private,
}).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;
return null;
}
catch(_e)
{

View File

@@ -16,5 +16,10 @@ export default defineEventHandler(async (event) => {
//@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

@@ -105,8 +105,8 @@ function reshapeContent(content: string, type: FileType): string | null
return content;
case "canvas":
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.nodes?.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 => e.color = typeof e.color === 'string' ? getColor(e.color) : undefined);
return JSON.stringify(data);
default:
case 'folder':

View File

@@ -2,7 +2,7 @@ export function hasPermissions(userPermissions: string[], neededPermissions: str
{
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)))
{

View File

@@ -1,13 +1,78 @@
import type { CanvasContent, CanvasEdge, CanvasNode } from "~/types/canvas";
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 { popper, tooltip } from "#shared/floating.util";
import { tooltip } from "#shared/floating.util";
import { History } from "#shared/history.util";
import { preview } from "#shared/proses";
import { SpatialGrid } from "#shared/physics.util";
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 Position = { x: number, y: 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` }
}
}
export class NodeEditable extends Node
{
edges: Set<EdgeEditable> = new Set();
@@ -418,6 +482,8 @@ export class Canvas
]),
]), this.transform,
]);
console.log(this.nodes.length, this.edges.length);
}
protected computeLimits()
@@ -440,6 +506,7 @@ export class Canvas
mount()
{
const dragMove = (e: MouseEvent) => {
e.preventDefault();
this.dragMove(e);
};
const dragEnd = (e: MouseEvent) => {
@@ -467,11 +534,11 @@ export class Canvas
this.firstX = pos.x;
this.firstY = pos.y;
window.addEventListener('mouseup', dragEnd, { passive: true });
window.addEventListener('mousemove', dragMove, { passive: true });
window.addEventListener('mouseup', dragEnd);
window.addEventListener('mousemove', dragMove);
this.dragStart(e);
}, { passive: true });
});
this.container.addEventListener('wheel', (e) => {
if((this._zoom >= Canvas.maxZoom && e.deltaY < 0) || (this._zoom <= this.containZoom && e.deltaY > 0))
return;
@@ -490,10 +557,10 @@ export class Canvas
this.lastDistance = distance(e.touches);
}
this.container.addEventListener('touchend', touchend, { passive: true });
this.container.addEventListener('touchcancel', touchcancel, { passive: true });
this.container.addEventListener('touchmove', touchmove, { passive: true });
}, { passive: true });
this.container.addEventListener('touchend', touchend);
this.container.addEventListener('touchcancel', touchcancel);
this.container.addEventListener('touchmove', touchmove);
});
const touchend = (e: TouchEvent) => {
if(e.touches.length > 1)
{
@@ -515,6 +582,7 @@ export class Canvas
this.container.removeEventListener('touchmove', touchmove);
};
const touchmove = (e: TouchEvent) => {
e.preventDefault();
const pos = center(e.touches);
this._x = this.visualX = this._x - (this.lastX - pos.x) / 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.updateTransform();
this.updateTransform(true);
};
this.computeLimits();
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)`;
clearTimeout(this.debouncedTimeout);
this.debouncedTimeout = setTimeout(this.updateScale.bind(this), 150);
if(debounce)
{
clearTimeout(this.debouncedTimeout);
this.debouncedTimeout = setTimeout(this.updateScale.bind(this), 50);
}
}
private updateScale()
@@ -562,7 +633,7 @@ export class Canvas
this.visualY = lerp(e, oldY, y);
this.visualZoom = lerp(e, oldZoom, zoom);
this.updateTransform();
this.updateTransform(true);
}, 50);
}
@@ -580,7 +651,7 @@ export class Canvas
this.lastX = e.clientX;
this.lastY = e.clientY;
this.updateTransform();
this.updateTransform(false);
}
protected dragEnd(e: MouseEvent) {}
@@ -846,9 +917,9 @@ export class CanvasEditor extends Canvas
e.stopImmediatePropagation();
}
override updateTransform()
override updateTransform(debounce: boolean = true)
{
super.updateTransform();
super.updateTransform(debounce);
this.pattern.parentElement?.classList.toggle('hidden', !this.preferences.value.gridSnap);
if(this.preferences.value.gridSnap)

File diff suppressed because one or more lines are too long

View File

@@ -1,14 +1,15 @@
import type { Ability, Alignment, Character, CharacterConfig, CharacterVariables, CompiledCharacter, FeatureItem, Level, MainStat, Resistance, SpellConfig, 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 characterConfig from '#shared/character-config.json';
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, span, text } from "#shared/dom.util";
import { followermenu, fullblocker, tooltip } from "#shared/floating.util";
import { clamp } from "#shared/general.util";
import markdown from "#shared/markdown.util";
import { getText } from "./i18n";
import type { User } from "~/types/auth";
import { MarkdownEditor } from "./editor.util";
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 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 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 = {
id: -1,
@@ -212,7 +215,32 @@ export const resistanceTexts: Record<Resistance, string> = {
'knowledge': 'Sorts de savoir',
'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(),
@@ -235,7 +263,7 @@ export const CharacterValidation = z.object({
people: z.string().nullable(),
level: z.number().min(1).max(20),
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())),
leveling: z.record(z.enum(LEVELS.map(String)), z.number().optional()),
abilities: z.record(z.enum(ABILITIES), z.number().optional()),
@@ -253,7 +281,15 @@ export class CharacterCompiler
{
protected _character!: Character;
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)
@@ -265,7 +301,15 @@ export class CharacterCompiler
{
this._character = 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)
{
@@ -328,6 +372,15 @@ export class CharacterCompiler
})
}
}
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)
{
if(!feature)
@@ -352,8 +405,8 @@ export class CharacterCompiler
case "list":
if(feature.action === 'add' && !this._result.lists[feature.list]!.includes(feature.item))
this._result.lists[feature.list]!.push(feature.item);
else
this._result.lists[feature.list] = this._result.lists[feature.list]!.filter((e: string) => e !== feature.item);
else if(feature.action === 'remove')
this._result.lists[feature.list]!.splice(this._result.lists[feature.list]!.findIndex((e: string) => e === feature.item), 1);
return;
case "value":
@@ -364,6 +417,9 @@ export class CharacterCompiler
this._buffer[feature.property]!.min = -Infinity;
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;
case "choice":
const choice = this._character.choices[feature.id];
@@ -386,8 +442,8 @@ export class CharacterCompiler
case "list":
if(feature.action === 'remove' && !this._result.lists[feature.list]!.includes(feature.item))
this._result.lists[feature.list]!.push(feature.item);
else
this._result.lists[feature.list] = this._result.lists[feature.list]!.filter(e => e !== feature.item);
else if(feature.action === 'add')
this._result.lists[feature.list]!.splice(this._result.lists[feature.list]!.findIndex((e: string) => e === feature.item), 1)
return;
case "value":
@@ -398,6 +454,9 @@ export class CharacterCompiler
this._buffer[feature.property]!.min = -Infinity;
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;
case "choice":
const choice = this._character.choices[feature.id];
@@ -410,60 +469,53 @@ export class CharacterCompiler
return;
}
}
protected compile(properties: string[])
protected compile(queue: string[])
{
const queue = properties;
for(let i = 0; i < queue.length; i++)
{
if(queue[i] === undefined || queue[i] === "") continue;
const property = queue[i]!;
const buffer = this._buffer[property];
if(property === "")
continue
if(buffer && buffer._dirty === true)
{
let sum = 0, shortcut = false;
for(let j = 0; j < buffer.list.length; j++)
{
if(typeof buffer.list[j]!.value === 'string') // Add or set a modifier
{
const modifier = this._buffer[buffer.list[j]!.value as string];
if(!modifier)
{
if(!queue.includes(buffer.list[j]!.value as string))
this._buffer[buffer.list[j]!.value as string] = { _dirty: false, list: [], min: -Infinity, value: 0 };
const item = buffer.list[j];
if(!item)
continue;
queue.push(property);
shortcut = true;
break;
}
else if(modifier._dirty)
if(typeof item.value === 'string') // Add or set a modifier
{
const modifier = this._buffer[item.value as string]!;
if(modifier._dirty)
{
//Put it back in queue since its dependencies haven't been resolved yet
queue.push(buffer.list[j]!.value as string);
queue.push(item.value as string);
queue.push(property);
shortcut = true;
break;
}
else
{
if(buffer.list[j]?.operation === 'add')
if(item.operation === 'add')
sum += modifier.value;
else if(buffer.list[j]?.operation === 'set')
else if(item.operation === 'set')
sum = modifier.value;
else if(buffer.list[j]?.operation === 'min')
this._buffer[property]!.min = modifier.value;
else if(item.operation === 'min')
buffer.min = modifier.value;
}
}
else
{
if(buffer.list[j]?.operation === 'add')
sum += buffer.list[j]!.value as number;
else if(buffer.list[j]?.operation === 'set')
sum = buffer.list[j]!.value as number;
else if(buffer.list[j]?.operation === 'min')
this._buffer[property]!.min = buffer.list[j]!.value as number;
if(item.operation === 'add')
sum += item.value as number;
else if(item.operation === 'set')
sum = item.value as number;
else if(item.operation === 'min')
buffer.min = item.value as number;
}
}
@@ -524,6 +576,14 @@ export class CharacterBuilder extends CharacterCompiler
}
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 = [
PeoplePicker,
LevelPicker,
@@ -541,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._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(), 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,
]));
@@ -573,7 +633,7 @@ export class CharacterBuilder extends CharacterCompiler
}
async save(leave: boolean = true)
{
if(this.id === 'new')
if(this.id === 'new' || this.id === '-1')
{
//@ts-ignore
this.id = this._character.id = this._result.id = await useRequestFetch()(`/api/character`, {
@@ -1052,8 +1112,6 @@ class AbilityPicker extends BuilderTab
const values = builder.values, compiled = builder.compiled;
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;
}
}
@@ -1175,7 +1233,7 @@ export class CharacterSheet
{
user: ComputedRef<User | null>;
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>)
{
@@ -1194,7 +1252,8 @@ export class CharacterSheet
}
else
throw new Error();
}).catch(() => {
}).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é.'),
@@ -1212,6 +1271,18 @@ export class CharacterSheet
const character = this.character.compiled;
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) },
@@ -1224,10 +1295,13 @@ export class CharacterSheet
] },
{ 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]' } });
this.container.replaceChildren(div('flex flex-col justify-center gap-1', [
], { 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(),
@@ -1350,11 +1424,11 @@ 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-60", [
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-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', size: 'small', 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")
]),
@@ -1367,31 +1441,30 @@ export class CharacterSheet
)
),
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', size: 'small', 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")
]),
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', size: 'small' }) : undefined,
character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme de jet') ], { href: 'regles/annexes/equipement#Les armes de jet', size: 'small' }) : undefined,
character.mastery.strength + character.mastery.dexterity > 0 ? proses('a', preview, [ text('Arme naturelle') ], { href: 'regles/annexes/equipement#Les armes naturelles', size: 'small' }) : undefined,
character.mastery.strength > 1 ? proses('a', preview, [ text('Arme standard') ], { href: 'regles/annexes/equipement#Les armes', size: 'small' }) : undefined,
character.mastery.strength > 1 ? proses('a', preview, [ text('Arme improvisée') ], { href: 'regles/annexes/equipement#Les armes improvisées', size: 'small' }) : undefined,
character.mastery.strength > 2 ? proses('a', preview, [ text('Arme lourde') ], { href: 'regles/annexes/equipement#Les armes lourdes', size: 'small' }) : undefined,
character.mastery.strength > 3 ? proses('a', preview, [ text('Arme à deux mains') ], { href: 'regles/annexes/equipement#Les armes à deux mains', size: 'small' }) : undefined,
character.mastery.dexterity > 0 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme maniable') ], { href: 'regles/annexes/equipement#Les armes maniables', size: 'small' }) : undefined,
character.mastery.dexterity > 1 && character.mastery.strength > 1 ? proses('a', preview, [ text('Arme à projectiles') ], { href: 'regles/annexes/equipement#Les armes à projectiles', size: 'small' }) : undefined,
character.mastery.dexterity > 1 && character.mastery.strength > 2 ? proses('a', preview, [ text('Arme longue') ], { href: 'regles/annexes/equipement#Les armes longues', size: 'small' }) : undefined,
character.mastery.shield > 0 ? proses('a', preview, [ text('Bouclier') ], { href: 'regles/annexes/equipement#Les boucliers', size: 'small' }) : undefined,
character.mastery.shield > 0 && character.mastery.strength > 3 ? proses('a', preview, [ text('Bouclier à deux mains') ], { href: 'regles/annexes/equipement#Les boucliers à deux mains', size: 'small' }) : 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', 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', label: 'Arme naturelle' }) : 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', label: 'Arme improvisée' }) : 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', 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', 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', 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', label: 'Arme longue' }) : 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', label: 'Bouclier à deux mains' }) : undefined,
]) : undefined,
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', size: 'small' }) : undefined,
character.mastery.armor > 1 ? proses('a', preview, [ text('Armure standard') ], { href: 'regles/annexes/equipement#Les armures', size: 'small' }) : undefined,
character.mastery.armor > 2 ? proses('a', preview, [ text('Armure lourde') ], { href: 'regles/annexes/equipement#Les armures lourdes', size: 'small' }) : 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', label: 'Armure standard' }) : undefined,
character.mastery.armor > 2 ? proses('a', preview, [ text('Armure lourde') ], { href: 'regles/annexes/equipement#Les armures lourdes', label: 'Armure lourde' }) : undefined,
]) : undefined,
div("grid grid-cols-2 gap-x-3 gap-y-1 text-sm", [
@@ -1492,7 +1565,7 @@ export class CharacterSheet
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.effect) ]),
div('flex flex-row ps-4 p-1 border-l-4 border-light-35 dark:border-dark-35', [ markdown(e.spell.description) ]),
]) : undefined }));
return [
div('flex flex-col gap-2', [
@@ -1546,7 +1619,7 @@ export class CharacterSheet
}, "px-2 py-1 text-sm font-normal");
toggleButton.disabled = state === 'given';
return foldable(() => [
markdown(spell.effect),
markdown(spell.description),
], [ div("flex flex-row justify-between gap-2", [
dom("span", { class: "text-lg font-bold", text: spell.name }),
div("flex flex-row items-center gap-6", [

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 { contextmenu, followermenu } from "./floating.util";
import { contextmenu, followermenu, popper, tooltip, type FloatState } from "./floating.util";
import { clamp } from "./general.util";
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 nav = link ? router.resolve(link) : undefined;
@@ -20,20 +21,21 @@ 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'}] })
}
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) => {
load.replaceWith(element);
state.current.replaceWith(element);
state.current = element;
}).catch(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)
{
/*
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
@@ -44,7 +46,7 @@ export function button(content: Node, onClick?: () => void, cls?: Class)
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()) } }, [ content ]);
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;
Object.defineProperty(btn, 'disabled', {
get: () => disabled,
@@ -75,6 +77,14 @@ export function buttongroup<T extends any>(options: Array<{ text: string, value:
}}}))
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;
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
@@ -390,9 +400,12 @@ export function numberpicker(settings?: { defaultValue?: number, change?: (value
}
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),
keydown: (e: KeyboardEvent) => {
if(field.disabled)
return;
switch(e.key)
{
case "ArrowUp":
@@ -427,12 +440,11 @@ export function foldable(content: NodeChildren | (() => NodeChildren), title: No
if(state && !_content)
{
_content = typeof content === 'function' ? content() : content;
//@ts-ignore
contentContainer.replaceChildren(..._content);
_content && contentContainer.replaceChildren(..._content.filter(e => !!e));
}
}
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) ]),
contentContainer
]);
@@ -451,8 +463,11 @@ export function toggle(settings?: { defaultValue?: boolean, change?: (value: boo
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
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: {
click: (e: Event) => {
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: function(e: Event) {
if(this.hasAttribute('data-disabled'))
return;
state = !state;
element.setAttribute('data-state', state ? "checked" : "unchecked");
settings?.change && settings.change(state);
@@ -461,6 +476,24 @@ 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') ]);
return element;
}
export function checkbox(settings?: { defaultValue?: boolean, change?: (this: HTMLElement, value: boolean) => void, disabled?: boolean, class?: { container?: Class, icon?: Class } })
{
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;
@@ -472,8 +505,7 @@ export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content:
this.toggleAttribute('data-focus', true);
focus = e.id;
const _content = typeof e.content === 'function' ? e.content() : e.content;
//@ts-expect-error
content.replaceChildren(..._content);
_content && content.replaceChildren(..._content?.filter(e => !!e));
}}}, e.title));
const _content = tabs.find(e => e.id === focus)?.content;
const content = div(['', settings?.class?.content], typeof _content === 'function' ? _content() : _content);
@@ -487,13 +519,174 @@ export function tabgroup(tabs: Array<{ id: string, title: NodeChildren, content:
configurable: false,
enumerable: false,
value: () => {
const _content = tabs.find(e => e.id === focus)?.content;
//@ts-expect-error
_content && content.replaceChildren(...(typeof _content === 'function' ? _content() : _content))
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
{
@@ -508,13 +701,13 @@ type ToastDom = ToastConfig & { dom: HTMLElement };
export type ToastType = 'info' | 'success' | 'error';
export class Toaster
{
private static _MAX_DRAG = 130;
private static _MAX_DRAG = 150;
private static _list: Array<ToastDom> = [];
private static _container: HTMLDivElement;
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);
}
static add(_config: ToastConfig)

View File

@@ -124,6 +124,9 @@ export class Content
if(Content._ready)
return Promise.resolve(true);
if(Content.initPromise)
return Content.initPromise;
Content.initPromise = new Promise(async (res) => {
try
{
@@ -764,11 +767,11 @@ export class Editor
if (location.current.dropTargets.length === 0)
return;
const target = location.current.dropTargets[0];
const target = location.current.dropTargets[0]!;
const instruction = extractInstruction(target.data);
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({
element: this.tree.container,

View File

@@ -1,7 +1,7 @@
import { iconExists, loadIcon } from 'iconify-icon';
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;
type Listener<K extends keyof HTMLElementEventMap> = | ((this: HTMLElement, ev: HTMLElementEventMap[K]) => any) | {
@@ -60,7 +60,7 @@ export function span(cls?: Class, text?: string): HTMLSpanElement
{
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);
@@ -120,45 +120,45 @@ export interface IconProperties
const iconCache: Map<string, HTMLElement> = new Map();
export function icon(name: string, properties?: IconProperties): HTMLElement
{
let el;
let element;
if(iconCache.has(name))
el = iconCache.get(name)!.cloneNode() as HTMLElement;
element = iconCache.get(name)!.cloneNode() as HTMLElement;
else
{
el = document.createElement('iconify-icon');
element = document.createElement('iconify-icon');
if(!iconExists(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?.inline && el.toggleAttribute('inline', properties?.inline);
el.toggleAttribute('noobserver', properties?.noobserver ?? true);
properties?.width && el.setAttribute('width', properties?.width.toString());
properties?.height && el.setAttribute('height', properties?.height.toString());
properties?.flip && el.setAttribute('flip', properties?.flip.toString());
properties?.rotate && el.setAttribute('rotate', properties?.rotate.toString());
properties?.mode && element.setAttribute('mode', properties?.mode.toString());
properties?.inline && element.toggleAttribute('inline', properties?.inline);
element.toggleAttribute('noobserver', properties?.noobserver ?? true);
properties?.width && element.setAttribute('width', properties?.width.toString());
properties?.height && element.setAttribute('height', properties?.height.toString());
properties?.flip && element.setAttribute('flip', properties?.flip.toString());
properties?.rotate && element.setAttribute('rotate', properties?.rotate.toString());
if(properties?.class)
{
el.setAttribute('class', mergeClasses(properties.class));
element.setAttribute('class', mergeClasses(properties.class));
}
if(properties?.style)
{
if(typeof properties.style === 'string')
{
el.setAttribute('style', properties.style);
element.setAttribute('style', properties.style);
}
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

View File

@@ -1,16 +1,30 @@
import type { Ability, AspectConfig, CharacterConfig, Feature, FeatureChoice, FeatureEquipment, FeatureItem, FeatureList, FeatureValue, i18nID, Level, MainStat, RaceConfig, Resistance, SpellConfig, TrainingLevel } from "~/types/character";
import type { Ability, AspectConfig, CharacterConfig, CommonItemConfig, DamageType, Feature, FeatureChoice, FeatureEquipment, FeatureItem, FeatureList, FeatureValue, i18nID, ItemConfig, Level, MainStat, RaceConfig, Resistance, SpellConfig, TrainingLevel, WeaponType } from "~/types/character";
import { div, dom, icon, span, text, type NodeChildren } from "#shared/dom.util";
import { MarkdownEditor } from "#shared/editor.util";
import { preview } from "#shared/proses";
import { button, combobox, foldable, input, multiselect, numberpicker, select, tabgroup, table, toggle, type Option } from "#shared/components.util";
import { button, checkbox, combobox, foldable, input, multiselect, numberpicker, optionmenu, select, tabgroup, table, toggle, type Option } from "#shared/components.util";
import { confirm, contextmenu, fullblocker, tooltip } from "#shared/floating.util";
import { ABILITIES, abilityTexts, ALIGNMENTS, alignmentTexts, elementTexts, LEVELS, MAIN_STATS, mainStatShortTexts, mainStatTexts, RESISTANCES, resistanceTexts, SPELL_ELEMENTS, SPELL_TYPES, spellTypeTexts } from "#shared/character.util";
import { ABILITIES, abilityTexts, ALIGNMENTS, alignmentTexts, damageTypeTexts, elementTexts, LEVELS, MAIN_STATS, mainStatShortTexts, mainStatTexts, RESISTANCES, resistanceTexts, SPELL_ELEMENTS, SPELL_TYPES, spellTypeTexts, weaponTypeTexts } from "#shared/character.util";
import characterConfig from "#shared/character-config.json";
import { getID } from "#shared/general.util";
import renderMarkdown, { renderText } from "#shared/markdown.util";
import markdown, { markdownReference, renderMDAsText } from "#shared/markdown.util";
import { Tree } from "#shared/tree";
import { getText } from "#shared/i18n";
import markdown from "#shared/markdown.util";
type Category = ItemConfig['category'];
type Rarity = ItemConfig['rarity'];
const categoryText: Record<Category, string> = {
'mundane': 'Objet inerte',
'armor': 'Armure',
'weapon': 'Arme',
'wondrous': 'Objet magique'
};
const rarityText: Record<Rarity, string> = {
'common': 'Commun',
'uncommon': 'Peu commun',
'rare': 'Rare',
'legendary': 'Légendaire'
};
const config = characterConfig as CharacterConfig;
export class HomebrewBuilder
@@ -19,12 +33,13 @@ export class HomebrewBuilder
private _tabs: HTMLElement & { refresh: () => void };
private _config: CharacterConfig;
private _editor: FeatureEditor;
private _featureEditor: FeaturePanel;
constructor(container: HTMLDivElement)
{
this._config = config as CharacterConfig;
this._editor = new FeatureEditor();
this._featureEditor = new FeaturePanel();
ItemPanel.config = this._config;
this._container = container;
this._tabs = tabgroup([
@@ -33,9 +48,10 @@ export class HomebrewBuilder
{ id: 'spells', title: [ text("Sorts") ], content: () => this.spells() },
{ id: 'aspects', title: [ text("Aspects") ], content: () => this.aspects() },
{ id: 'actions', title: [ text("Actions") ], content: () => this.actions() },
], { focused: 'actions', class: { container: 'flex-1 outline-none max-w-full w-full overflow-y-auto', tabbar: 'flex w-full flex-row gap-4 items-center justify-center relative' } });
{ id: 'items', title: [ text("Objets") ], content: () => this.items() },
], { focused: 'training', class: { container: 'flex-1 outline-none max-w-full w-full overflow-y-auto', tabbar: 'flex w-full flex-row gap-4 items-center justify-center relative' } });
this._tabs.children[0]?.appendChild(tooltip(button(icon('radix-icons:clipboard'), () => this.save(), 'p-1'), 'Copier', 'bottom'))
this._tabs.children[0]?.appendChild(tooltip(button(icon('radix-icons:clipboard'), () => this.save(), 'p-1'), 'Copier', 'bottom'));
this._container.appendChild(div('flex flex-1 flex-col justify-start items-center px-8 w-full h-full overflow-y-hidden', [
this._tabs
]));
@@ -62,9 +78,10 @@ export class HomebrewBuilder
}
const render = (people: string, level: Level, feature: string) => {
let element = dom("div", { class: ["border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50"], listeners: { click: e => {
this.edit(config.features[feature]!).then(e => {
FeaturePanel.edit(config.features[feature]!).then(e => {
config.features[feature] = e;
element.replaceChildren(markdown(config.features[feature]!.description, undefined, { tags: { a: preview } }));
});
}).catch(e => {});
}, contextmenu: (e) => {
e.preventDefault();
const context = contextmenu(e.clientX, e.clientY, [
@@ -110,9 +127,10 @@ export class HomebrewBuilder
}
const render = (stat: MainStat, level: TrainingLevel, feature: string) => {
let element = dom("div", { class: ["border border-light-40 dark:border-dark-40 cursor-pointer px-2 py-1 w-[400px] hover:border-light-50 dark:hover:border-dark-50"], listeners: { click: e => {
this.edit(config.features[feature]!).then(e => {
FeaturePanel.edit(config.features[feature]!).then(e => {
config.features[feature] = e;
element.replaceChildren(markdown(config.features[feature]!.description, undefined, { tags: { a: preview } }));
});
}).catch(e => {});
}, contextmenu: (e) => {
e.preventDefault();
const context = contextmenu(e.clientX, e.clientY, [
@@ -219,7 +237,7 @@ export class HomebrewBuilder
dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Portée'), select<'personnal' | number>([{ text: 'Toucher', value: 0 }, { text: 'Personnel', value: 'personnal' }, { text: '3 cases', value: 3 }, { text: '6 cases', value: 6 }, { text: '9 cases', value: 9 }, { text: '12 cases', value: 12 }, { text: '18 cases', value: 18 }], { change: (value) => spell.range = value, defaultValue: spell.range, class: { container: '!m-0 !h-9 w-full' } }), ]),
dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Tags'), multiselect([{ text: 'Dégâts', value: 'damage' }, { text: 'Buff', value: 'buff' }, { text: 'Debuff', value: 'debuff' }, { text: 'Support', value: 'support' }, { text: 'Tank', value: 'tank' }, { text: 'Mouvement', value: 'movement' }, { text: 'Utilitaire', value: 'utilitary' }], { change: (value) => spell.tags = value, defaultValue: spell.tags, class: { container: '!m-0 !h-9 w-full' } }), ]),
dom('label', { class: 'flex flex-col items-center justify-start gap-2 flex-1 *:text-center' }, [ text('Concentration'), toggle({ change: (value) => spell.concentration = value, defaultValue: spell.concentration, class: { container: '!m-0 !flex-none' } }), ]),
], [ div('gap-4 px-4 flex', [ input('text', { input: (value) => spell.name = value, defaultValue: spell.name, class: '!m-0 w-64' }), input('text', { input: (value) => spell.effect = value, defaultValue: spell.effect, class: '!m-0 w-full' }),div('flex flex-row justify-center gap-2', [ button(icon('radix-icons:trash', { noobserver: true }), () => remove(spell), 'p-1') ]) ]) ], { class: { container: 'border-light-35 dark:border-dark-35 py-1', content: 'gap-2 px-4 py-1 flex items-center *:flex-1' }, open: false });
], [ div('gap-4 px-4 flex', [ input('text', { input: (value) => spell.name = value, defaultValue: spell.name, class: '!m-0 w-64' }), input('text', { input: (value) => spell.description = value, defaultValue: spell.description, class: '!m-0 w-full' }),div('flex flex-row justify-center gap-2', [ button(icon('radix-icons:trash', { noobserver: true }), () => remove(spell), 'p-1') ]) ]) ], { class: { container: 'border-light-35 dark:border-dark-35 py-1', content: 'gap-2 px-4 py-1 flex items-center *:flex-1' }, open: false });
}
const add = () => {
this._config.spells.push({
@@ -230,8 +248,9 @@ export class HomebrewBuilder
cost: 1,
speed: 'action',
elements: [],
effect: '',
description: '',
concentration: false,
range: 0,
tags: [],
});
@@ -243,7 +262,7 @@ export class HomebrewBuilder
confirm('Voulez vous vraiment supprimer ce sort ?').then(e => {
if(e)
{
config.spells = config.spells.filter(e => e !== spell);
this._config.spells = this._config.spells.filter(e => e !== spell);
const element = redraw();
content.parentElement?.replaceChild(element, content);
@@ -259,56 +278,164 @@ export class HomebrewBuilder
{
let editing: { type: 'action' | 'reaction' | 'freeaction' | 'passive', id: string } | undefined;
const render = (type: 'action' | 'reaction' | 'freeaction' | 'passive', feature: { id: string, name: string, description: string, cost?: number }) => {
return div('flex flex-col gap-1', [
div('flex flex-row justify-between', [ input('text', { defaultValue: feature.name, input: value => feature.name = value, placeholder: 'Nom', class: '!mx-0 w-80' }), div('flex flex-row gap-2 items-center', [ type === 'action' || type === 'reaction' ? div('flex flex-row items-center', [ numberpicker({ defaultValue: feature?.cost ?? 0, input: value => feature.cost = value, class: '!mx-1' }), text(`point${(feature?.cost ?? 0) > 1 ? 's' : ''}`)]) : undefined, div('flex flex-row items-center gap-2', [ span('text-sm text-light-70 dark:text-dark-70', type), tooltip(button(icon('radix-icons:pencil-1'), () => {
}, 'p-1'), 'Modifier', 'left'), tooltip(button(icon('radix-icons:trash'), () => remove(type, feature.id), 'p-1'), 'Supprimer', 'right') ]) ])]),
markdown(getText(feature.description), undefined, { tags: { a: preview }, class: 'px-2' }),
]);
const md = markdownReference(getText(feature.description), undefined, { tags: { a: preview }, class: 'ms-2 px-2 py-1 border-l-4 border-light-30 dark:border-dark-30' });
const buttons = div('flex flex-row items-center gap-2', [ span('text-sm text-light-70 dark:text-dark-70', type), tooltip(button(icon('radix-icons:pencil-1'), () => edit(type, feature.id), 'p-1'), 'Modifier', 'left'), tooltip(button(icon('radix-icons:trash'), () => remove(type, feature.id), 'p-1'), 'Supprimer', 'right') ]);
return {
dom: div('flex flex-col gap-2', [
div('flex flex-row justify-between', [ input('text', { defaultValue: feature.name, input: value => feature.name = value, placeholder: 'Nom', class: '!mx-0 w-80' }), div('flex flex-row gap-2 items-center', [ type === 'action' || type === 'reaction' ? div('flex flex-row items-center', [ numberpicker({ defaultValue: feature?.cost ?? 0, input: value => feature.cost = value, class: '!mx-1', max: type === 'action' ? 3 : 2, min: 0 }), text(`point${(feature?.cost ?? 0) > 1 ? 's' : ''}`)]) : undefined, buttons ])]),
md.current,
]),
buttons,
md,
type,
id: feature.id,
};
}
const add = (type: 'action' | 'reaction' | 'freeaction' | 'passive') => {
const feature: { id: string, name: string, description: string, cost?: number } = {
id: getID(),
name: '',
description: getID(),
description: getID(), // i18nID
cost: type === 'action' || type === 'reaction' ? 1 : undefined,
}
this._config.texts[feature.description] = { 'fr_FR': '', default: '' };
this._config[type][feature.id] = feature;
const element = redraw();
content.parentElement?.replaceChild(element, content);
content = element;
const option = render(type, feature);
options.push(option);
optionHolder.appendChild(option.dom);
};
const remove = (type: 'action' | 'reaction' | 'freeaction' | 'passive', id: string) => {
confirm('Voulez vous vraiment supprimer cet effet ?').then(e => {
const feature = this._config[type][id]!;
confirm(`Voulez vous vraiment supprimer l'effet "${feature.name}" ?`).then(e => {
if(e)
{
delete this._config.texts[feature.description];
delete this._config[type][id];
const element = redraw();
content.replaceWith(element);
content = element;
const idx = options.findIndex(e => e.type === type && e.id === id);
options.splice(idx, 1)[0]?.dom.remove();
}
});
};
const redraw = () => div('flex flex-col gap-4', [...Object.values(this._config.action).map(e => render('action', e)), ...Object.values(this._config.reaction).map(e => render('reaction', e)), ...Object.values(this._config.freeaction).map(e => render('freeaction', e)), ...Object.values(this._config.passive).map(e => render('passive', e))]);
let content = redraw();
return [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), () => add('passive'), 'p-1') ]), content ] ) ];
const edit = (type: 'action' | 'reaction' | 'freeaction' | 'passive', id: string) => {
const feature = this._config[type][id]!;
const option = options.find(e => e.type === type && e.id === id);
if(editing)
{
const idx = options.findIndex(e => e.id === editing!.id && e.type === editing!.type);
const rerender = render(editing.type, this._config[editing.type][editing.id]!);
options[idx]?.dom.replaceWith(rerender.dom);
options[idx] = rerender;
}
editing = { id, type };
const buttons = div('flex flex-row items-center gap-2', [ span('text-sm text-light-70 dark:text-dark-70', type), tooltip(button(icon('radix-icons:check'), () => {
this._config.texts[feature.description]!.default = editor.content;
this._config.texts[feature.description]!['fr_FR'] = editor.content;
const rerender = render(type, feature);
option!.buttons.replaceWith(rerender.buttons);
option!.buttons = rerender.buttons;
option!.md.current.replaceWith(rerender.md.current);
option!.md = rerender.md;
editing = undefined;
}, 'p-1'), 'Valider', 'left'), tooltip(button(icon('radix-icons:cross-1'), () => {
const rerender = render(type, feature);
option!.buttons.replaceWith(rerender.buttons);
option!.buttons = rerender.buttons;
option!.md.current.replaceWith(rerender.md.current);
option!.md = rerender.md;
editing = undefined;
}, 'p-1'), 'Rejeter', 'right') ]);
option!.buttons.replaceWith(buttons);
option!.buttons = buttons;
const editor = MarkdownEditor.singleton;
editor.content = getText(feature.description);
editor.onChange = (value) => {};
const editorDom = div('p-1 border border-light-35 dark:border-dark-35', [ editor.dom ]);
option!.md.current.replaceWith(editorDom);
option!.md.current = editorDom;
}
const options = [...Object.values(this._config.action).map(e => render('action', e)), ...Object.values(this._config.reaction).map(e => render('reaction', e)), ...Object.values(this._config.freeaction).map(e => render('freeaction', e)), ...Object.values(this._config.passive).map(e => render('passive', e))];
const optionHolder = div('flex flex-col gap-4', options.map(e => e.dom));
return [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), optionmenu([{ title: 'Action', click: () => add('action') }, { title: 'Réaction', click: () => add('reaction') }, { title: 'Action libre', click: () => add('freeaction') }, { title: 'Passif', click: () => add('passive') }], { position: 'left-start' }), 'p-1') ]), optionHolder ] ) ];
}
edit(feature: Feature): Promise<Feature>
items()
{
const promise: Promise<Feature> = this._editor.edit(feature).then(f => {
this._config.features[feature.id] = f;
return f;
}).catch((e) => { return feature; }).finally(() => {
setTimeout(popup.close, 150);
this._editor.container.setAttribute('data-state', 'inactive');
});
const popup = fullblocker([this._editor.container], {
priority: true, closeWhenOutside: false,
});
setTimeout(() => this._editor.container.setAttribute('data-state', 'active'), 1);
return promise;
const defaultItem = (category: Category): ItemConfig => {
const common: CommonItemConfig = {
id: getID(),
name: '',
description: getID(), // i18nID
rarity: 'common',
equippable: false,
consummable: false,
}
switch(category)
{
case 'armor':
return { ...common, category: category, health: 0, absorb: { percent: 0, static: 0 }, type: 'light' };
case 'weapon':
return { ...common, category: category, damage: { type: 'slashing', value: '0' }, type: ['classic'] };
case 'wondrous':
case 'mundane':
return { ...common, category: category };
}
};
const render = (item: ItemConfig) => {
return {
dom: div('flex flex-col gap-2 border border-light-35 dark:border-dark-35 p-1', [
div('flex flex-row justify-between', [ span('text-xl font-bold ps-2', item.name), div('flex flex-row gap-2 items-center', [ div('flex flex-row items-center gap-2', [ tooltip(button(icon('radix-icons:pencil-1'), () => edit(item), 'p-1'), 'Modifier', 'top'), tooltip(button(icon('radix-icons:trash'), () => remove(item), 'p-1'), 'Supprimer', 'top') ]) ])]),
div('flex flex-row gap-2 px-4 items-center', [ span('text-sm text-light-70 dark:text-dark-70', categoryText[item.category]), text('-'), span('text-sm text-light-70 dark:text-dark-70', rarityText[item.rarity]), ]),
markdown(getText(item.description), undefined, { tags: { a: preview }, class: 'px-2 py-1 border-l-4 border-light-30 dark:border-dark-30 h-full' }),
]),
item,
};
};
const add = (category: Category) => {
const item = defaultItem(category);
this._config.texts[item.description!] = { 'fr_FR': '', default: '' };
this._config.items[item.id!] = item;
const option = render(item);
options.push(option);
optionHolder.appendChild(option.dom);
};
const remove = (item: ItemConfig) => {
confirm(`Voulez vous vraiment supprimer l'effet "${item.name}" ?`).then(e => {
if(e)
{
delete this._config.texts[item.description];
delete this._config.items[item.id];
const idx = options.findIndex(e => e.item === item);
options.splice(idx, 1)[0]?.dom.remove();
}
});
};
const edit = (item: ItemConfig) => {
ItemPanel.edit(item).then(f => {
const idx = options.findIndex(e => e.item === item);
this._config.items[item.id] = f;
const element = render(f);
options[idx]?.dom.replaceWith(element.dom);
options[idx] = element;
}).catch((e) => {});
}
const options = Object.values(this._config.items).map(e => render(e));
const optionHolder = div('grid grid-cols-3 gap-2', options.map(e => e.dom));
return [ div('flex px-8 py-4 flex-col gap-4', [ div('flex flex-row-reverse', [ button(icon('radix-icons:plus'), optionmenu([{ title: 'Objet inerte', click: () => add('mundane') }, { title: 'Armure', click: () => add('armor') }, { title: 'Arme', click: () => add('weapon') }, { title: 'Objet magique', click: () => add('wondrous') }], { position: 'left-start' }), 'p-1') ]), optionHolder ] ) ];
}
private save()
{
@@ -316,33 +443,253 @@ export class HomebrewBuilder
}
}
export class FeatureEditor
type FeatureOption = Partial<FeatureValue | FeatureEquipment | FeatureList | FeatureChoice> & { id: string };
class FeatureEditor
{
private _container: HTMLDivElement;
private _list: Record<string, FeatureOption> | FeatureOption[];
private _id: string;
private _draft: boolean;
private _success?: Function;
private _failure?: Function;
private _feature?: Feature;
private _arr: boolean;
private option!: FeatureOption;
private _idInput: HTMLInputElement;
private _table: HTMLDivElement;
container!: HTMLElement;
constructor()
constructor(list: Record<string, FeatureOption> | FeatureOption[], id: string, draft: boolean)
{
this._idInput = dom("input", { attributes: { 'disabled': true }, class: `mx-4 text-light-70 dark:text-dark-70 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-25 dark:bg-dark-25 border-light-30 dark:border-dark-30` });
this._table = div('grid grid-cols-2 gap-4 px-2');
this._container = dom('div', { attributes: { 'data-state': 'inactive' }, class: 'border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-2 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]' }, [
this._arr = Array.isArray(list);
this._list = list;
this._id = id;
if(this._arr ? !(list as FeatureOption[]).find(e => e.id === id) : !list.hasOwnProperty(id))
throw new Error();
this.read();
this._draft = draft;
this.container = div();
if(draft)
this.edit();
else
this.show();
}
private read()
{
this.option = JSON.parse(JSON.stringify(this._arr ? (this._list as FeatureOption[]).find(e => e.id === this._id)! : (this._list as Record<string, FeatureOption>)[this._id]!));
}
private update()
{
if(this._arr)
{
const idx = (this._list as FeatureOption[]).findIndex(e => e.id === this.option.id);
if(idx === -1)
throw new Error();
(this._list as FeatureOption[])[idx]! = this.option;
}
else
{
(this._list as Record<string, FeatureOption>)[this.option.id] = this.option;
}
}
private delete()
{
if(this._arr)
{
const idx = (this._list as FeatureOption[]).findIndex(e => e.id === this.option.id);
if(idx === -1)
throw new Error();
(this._list as FeatureOption[]).splice(idx, 1);
}
else
{
delete (this._list as Record<string, FeatureOption>)[this.option.id];
}
}
private show()
{
const content = div('border border-light-30 dark:border-dark-30 col-span-1', [ div('flex justify-between items-center', [
div('px-4 flex items-center h-full', [ markdown(textFromEffect(this.option), undefined, { tags: { a: preview } }) ]),
div('flex', [ tooltip(button(icon('radix-icons:pencil-1'), () => this.edit(), 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Modifieur", "bottom"), tooltip(button(icon('radix-icons:trash'), () => {
this.delete();
this.container.remove();
}, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Supprimer", "bottom") ])
]) ]);
this.container.replaceWith(content);
this.container = content;
}
private static match(effect: FeatureOption): Partial<FeatureOption> | undefined
{
switch(effect.category)
{
case 'value':
return flattenFeatureChoices.findLast(e => e.category === 'value' && e.property === effect.property);
case 'choice':
return flattenFeatureChoices.findLast(e => e.category === 'choice');
case 'list':
return flattenFeatureChoices.findLast(e => e.category === 'list' && e.list === effect.list);
}
}
private editByCategory(buffer: FeatureOption)
{
let top: NodeChildren = [], bottom: NodeChildren = [];
switch(this.option.category)
{
case 'value':
return this.editValue(buffer as Partial<FeatureValue>);
case 'list':
return this.editList(buffer as Partial<FeatureList>);
case 'choice':
return this.editChoice(buffer as Partial<FeatureChoice>);
default: break;
}
return { top, bottom };
}
private editValue(buffer: Partial<FeatureValue | FeatureEquipment>)
{
const valueVariable = () => typeof buffer.value === 'number' ? numberpicker({ defaultValue: buffer.value, input: (value) => { buffer.value = value; summaryText.textContent = textFromEffect(buffer); }, class: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-[80px]' }) : select<`modifier/${MainStat}` | false>([...Object.entries(mainStatShortTexts).map(e => ({ text: 'Mod. de ' + e[1], value: `modifier/${e[0]}` as `modifier/${MainStat}` })), buffer.operation === 'add' ? undefined : { text: 'Interdit', value: false }], { class: { container: 'w-[160px] bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px]' }, defaultValue: buffer.value, change: (value) => { buffer.value = value; summaryText.textContent = textFromEffect(buffer); } });
const summaryText = text(textFromEffect(buffer));
let valueSelection = valueVariable();
return { top: [
select([ (['action', 'reaction'].includes(buffer.property ?? '') ? undefined : { text: '+', value: 'add' }), (['speed', 'capacity', 'action', 'reaction'].includes(buffer.property ?? '') || ['defense/'].some(e => buffer.property?.startsWith(e))) ? { text: '=', value: 'set' } : undefined ], { defaultValue: buffer.operation, change: (value) => { buffer.operation = value as 'add' | 'set'; summaryText.textContent = textFromEffect(buffer); }, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-[80px]' } }),
valueSelection,
tooltip(button(icon('radix-icons:update'), () => {
buffer.value = (typeof buffer.value === 'number' ? '' as any as false : 0);
const newValueSelection = valueVariable();
valueSelection.replaceWith(newValueSelection);
valueSelection = newValueSelection;
summaryText.textContent = textFromEffect(buffer);
}, 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Changer d\'editeur', 'bottom'),
], bottom: [
div('px-2 py-1 flex items-center flex-1', [summaryText])
] };
}
private editList(buffer: Partial<FeatureList>)
{
let list: Option<string>[];
if(buffer.action === 'add')
{
if(buffer.list === 'spells')
{
list = config.spells.map(e => ({ text: e.name, render: () => div('flex flex-col', [ div('flex flex-row justify-between', [ dom('span', { text: e.name, class: 'font-bold' }), div('flex flex-row gap-8', [ dom('span', { class: 'italic', text: `Rang ${e.rank === 4 ? 'spécial' : e.rank}` }), dom('span', { text: spellTypeTexts[e.type] }) ]) ]), div('text-sm text-light-70 dark:text-dark-70', [ text(renderMDAsText(e.description)) ]) ]), value: e.id }));
}
else if(buffer.list)
{
list = Object.values(config[buffer.list]).map(e => ({ text: e.name, render: () => div('flex flex-col', [ div('flex flex-row justify-between', [ dom('span', { text: e.name, class: 'font-bold' }) ]), div('text-sm text-light-70 dark:text-dark-70', [ text(renderMDAsText(getText(e.description))) ]) ]), value: e.id }));
}
}
else
{
list = (Object.values(config.features).flatMap(e => e.effect).filter(e => e.category === 'list' && e.list === buffer.list && e.action === 'add') as FeatureList[]).map((e) => e.list === 'spells' ? config.spells.find(f => f.id === e.item)! : config[e.list][e.item]!).map((e) => ({ text: e.name, render: () => div('flex flex-col', [ div('flex flex-row justify-between', [ dom('span', { text: e.name, class: 'font-bold' }) ]), div('text-sm text-light-70 dark:text-dark-70', [ text(renderMDAsText(getText(e.description))) ]) ]), value: e.id }));
}
return {
top: [ select([ { text: 'Ajouter', value: 'add' }, { text: 'Supprimer', value: 'remove' } ], { defaultValue: buffer.action, change: (value) => {
buffer.action = value as 'add' | 'remove';
this.edit();
}, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-32' } }) ],
bottom: [ combobox(list!, { defaultValue: buffer.item, change: (item) => buffer.item = item, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-full overflow-hidden truncate', option: 'max-h-[90px] text-sm' }, fill: 'contain' }) ]
}
}
private editChoice(buffer: Partial<FeatureChoice>)
{
const availableChoices: Option<Partial<FeatureValue | FeatureList>>[] = featureChoices.filter(e => (e?.value as FeatureOption)?.category !== 'choice').map(e => { if(e) e.value = Array.isArray(e.value) ? e.value.filter(f => (f?.value as FeatureOption)?.category !== 'choice') : e.value; return e; }) as Option<Partial<FeatureValue | FeatureList>>[];
const addChoice = () => {
const choice: { text: string; effects: (Partial<FeatureValue | FeatureList>)[]; } = { effects: [{ id: getID() }], text: '' };
buffer.options ??= [];
buffer.options.push(choice as FeatureChoice["options"][number]);
return choice;
};
const addEffect = (choice: { text: string; effects: (Partial<FeatureValue | FeatureList>)[] }) => {
const effect: (Partial<FeatureValue | FeatureList>) = { id: getID() };
choice.effects.push(effect);
return effect;
};
const renderEffect = (option: { text: string; effects: (Partial<FeatureValue | FeatureList>)[] }, effect: Partial<FeatureValue | FeatureList>) => {
const { top: _top, bottom: _bottom } = this.editByCategory(effect as FeatureOption);
let element = div('border border-light-30 dark:border-dark-30 col-span-2 row-span-2', [ div('flex justify-between items-stretch', [
div('flex flex-row flex-1', [
combobox(availableChoices, { defaultValue: FeatureEditor.match(effect as FeatureOption) as Partial<FeatureValue | FeatureList> | undefined, class: { container: 'bg-light-25 dark:bg-dark-25 w-[300px] -m-px hover:z-10 h-[36px]' }, fill: 'cover', change: (e: Partial<FeatureValue | FeatureList>) => {
const idx = option.effects.findIndex(e => e === effect);
option.effects[idx] = effect = { ...e, id: effect.id };
const _element = renderEffect(option, effect);
element.replaceWith(_element);
element = _element;
} }),
..._top,
]),
div('flex', [ tooltip(button(icon('radix-icons:trash'), () => { option.effects = option.effects.filter(e => e === effect); element.remove(); }, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Supprimer", "bottom") ])
]), div('flex border-t border-light-35 dark:border-dark-35 max-h-[300px] min-h-[36px] overflow-y-auto overflow-x-hidden', _bottom) ]);
return element;
}
const renderOption = (option: { text: string; effects: (Partial<FeatureValue | FeatureList>)[] }, state: boolean) => {
const effects = div('flex flex-col -m-px flex flex-col ms-px ps-8 w-full', option.effects.map(e => renderEffect(option, e)));
let _content = foldable([ effects ], [ div('flex flex-row flex-1 justify-between', [ input('text', { defaultValue: option.text, input: (value) => option.text = value, placeholder: 'Nom de l\'option', class: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] flex-shrink-1' }), div('flex flex-row flex-shrink-1', [ tooltip(button(icon('radix-icons:plus'), () => effects.appendChild(renderEffect(option, addEffect(option))), 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Nouvel effet', 'bottom'), , tooltip(button(icon('radix-icons:trash'), () => {
_content.remove();
buffer.options?.splice(buffer.options.findIndex(e => e !== option), 1);
}, 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Supprimer', 'bottom') ]) ]) ], { class: { title: 'border-b border-light-35 dark:border-dark-35', icon: 'w-[34px] h-[34px]', content: 'border-b border-light-35 dark:border-dark-35' }, open: state });
return _content;
}
const list = div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35 gap-2', buffer.options?.map(e => renderOption(e, false)) ?? []);
return {
top: [ input('text', { defaultValue: buffer.text, input: (value) => (buffer as FeatureChoice).text = value, class: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-full', placeholder: 'Description' }), tooltip(button(icon('radix-icons:plus'), () => list.appendChild(renderOption(addChoice(), true)), 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Nouvelle option', 'bottom') ],
bottom: [ list ],
}
}
private edit()
{
const redraw = () => {
const { top, bottom } = this.editByCategory(this.option);
return div('border border-light-30 dark:border-dark-30 col-span-2 row-span-2', [ div('flex justify-between items-stretch', [
div('flex flex-row flex-1', [
combobox(featureChoices, { defaultValue: FeatureEditor.match(this.option), class: { container: 'bg-light-25 dark:bg-dark-25 w-[300px] -m-px hover:z-10 h-[36px]' }, fill: 'cover', change: (e) => {
this.option = { id: this.option.id, ...e } as FeatureOption;
content = redraw();
this.container.replaceWith(content);
this.container = content;
} }),
...top,
]),
div('flex', [ tooltip(button(icon('radix-icons:check'), () => { this.update(); this.read(); this.show(); this._draft = false; }, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Valider", "bottom"), tooltip(button(icon('radix-icons:cross-1'), () => { if(this._draft) { this.delete(); this.container.remove(); } else { this.read(); this.show(); } }, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Annuler", "bottom") ])
]), div('flex border-t border-light-35 dark:border-dark-35 max-h-[300px] min-h-[36px] overflow-y-auto overflow-x-hidden', bottom) ]);
}
let content = redraw();
this.container.replaceWith(content);
this.container = content;
}
}
export class FeaturePanel
{
static render(feature: Feature, success: (feature: Feature) => void, failure: (feature: Feature) => void)
{
const _feature = JSON.parse(JSON.stringify(feature)) as Feature;
const effectContainer = div('grid grid-cols-2 gap-4 px-2', _feature.effect.map(e => new FeatureEditor(_feature.effect!, e.id, false).container));
MarkdownEditor.singleton.content = getText(_feature.description);
MarkdownEditor.singleton.onChange = (value) => ItemPanel.config.texts[_feature.description]!.default = value;
return dom('div', { attributes: { 'data-state': 'inactive' }, class: 'border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-2 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]' }, [
div('flex flex-row justify-between items-center', [
tooltip(button(icon('radix-icons:check', { width: 20, height: 20 }), () => {
this._success!(this._feature);
success!(_feature);
MarkdownEditor.singleton.onChange = undefined;
}, 'p-1'), 'Valider', 'left'),
dom('label', { class: 'flex justify-center items-center my-2' }, [
dom('span', { class: 'pb-1 md:p-0', text: "ID" }),
this._idInput
input("text", { defaultValue: _feature.id, disabled: true, class: `mx-4 text-light-70 dark:text-dark-70 appearance-none outline-none px-3 py-1 focus:shadow-raw transition-[box-shadow] border bg-light-25 dark:bg-dark-25 border-light-30 dark:border-dark-30` })
]),
tooltip(button(icon('radix-icons:cross-1', { width: 20, height: 20 }), () => {
this._failure!(this._feature);
failure!(feature);
MarkdownEditor.singleton.onChange = undefined;
}, 'p-1'), 'Annuler', 'left'),
]),
@@ -350,8 +697,8 @@ export class FeatureEditor
div('flex w-full items-center justify-between', [
dom('span', { class: 'pb-1 md:p-0', text: "Description" }),
tooltip(button(icon('radix-icons:clipboard', { width: 20, height: 20 }), () => {
MarkdownEditor.singleton.content = this._feature?.effect.map(e => textFromEffect(e)).join('\n') ?? this._feature?.description ?? MarkdownEditor.singleton.content;
if(this._feature?.description) this._feature.description = MarkdownEditor.singleton.content;
MarkdownEditor.singleton.content = _feature?.effect.map(e => textFromEffect(e)).join('\n') ?? _feature?.description ?? MarkdownEditor.singleton.content;
if(_feature?.description) _feature.description = MarkdownEditor.singleton.content;
}, 'p-1'), 'Description automatique', 'left'),
]),
div('p-1 border border-light-40 dark:border-dark-40 w-full bg-light-25 dark:bg-dark-25 min-h-48 max-h-[32rem]', [ MarkdownEditor.singleton.dom ]),
@@ -360,193 +707,102 @@ export class FeatureEditor
div('flex flex-row justify-between', [
dom('h3', { class: 'text-lg font-bold', text: 'Effets' }),
tooltip(button(icon('radix-icons:plus', { width: 20, height: 20 }), () => {
this._table.appendChild(this._edit({ id: getID() }));
const f = { id: getID(), };
//@ts-expect-error
_feature.effect.push(f);
effectContainer.appendChild(new FeatureEditor(_feature.effect, f.id, true).container);
}, 'p-1'), 'Ajouter', 'left'),
]),
this._table,
effectContainer,
])
]);
}
edit(feature: Feature): Promise<Feature>
static edit(feature: Feature): Promise<Feature>
{
return new Promise((success, failure) => {
this._success = success;
this._failure = failure;
this._feature = JSON.parse(JSON.stringify(feature)) as Feature;
this._table.replaceChildren(...this._feature.effect.map(this._renderEffect.bind(this)));
this._idInput.value = this._feature.id;
MarkdownEditor.singleton.onChange = (e) => this._feature!.description = e;
MarkdownEditor.singleton.content = this._feature.description;
let container: HTMLElement, close: Function;
return new Promise<Feature>((success, failure) => {
container = FeaturePanel.render(feature, success, failure);
close = fullblocker([container], {
priority: true, closeWhenOutside: false,
}).close;
setTimeout(() => container.setAttribute('data-state', 'active'), 1);
}).finally(() => {
setTimeout(close, 150);
container.setAttribute('data-state', 'inactive');
});
}
private _renderEffect(effect: Partial<FeatureItem>): HTMLDivElement
}
export class ItemPanel
{
static config: CharacterConfig;
static render(item: ItemConfig, success: (item: ItemConfig) => void, failure: (item: ItemConfig) => void)
{
const content = div('border border-light-30 dark:border-dark-30 col-span-1', [ div('flex justify-between items-center', [
div('px-4 flex items-center h-full', [ renderMarkdown(textFromEffect(effect), undefined, { tags: { a: preview } }) ]),
div('flex', [ tooltip(button(icon('radix-icons:pencil-1'), () => {
content.replaceWith(this._edit(effect));
}, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Modifieur", "bottom"), tooltip(button(icon('radix-icons:trash'), () => {
this._feature!.effect = this._feature!.effect.filter(e => e.id !== effect.id);
content.remove();
}, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Supprimer", "bottom") ])
]) ]);
return content;
}
private _edit(effect: Partial<FeatureItem>): HTMLDivElement
{
const match = (effect: FeatureItem): Partial<FeatureItem> | undefined => {
switch(effect.category)
{
case 'value':
return flattenFeatureChoices.findLast(e => e.category === 'value' && e.property === effect.property);
case 'choice':
return flattenFeatureChoices.findLast(e => e.category === 'choice');
case 'list':
return flattenFeatureChoices.findLast(e => e.category === 'list' && e.list === effect.list);
}
};
const approve = () => {
const idx = this._feature!.effect.findIndex(e => e.id === _buffer.id);
if(idx === -1)
this._feature!.effect.push(_buffer);
else
this._feature!.effect[idx] = _buffer;
this._table.replaceChild(this._renderEffect(_buffer), content);
}, reject = () => {
const idx = this._feature!.effect.findIndex(e => e.id === _buffer.id);
if(idx === -1)
content.remove();
else
this._table.replaceChild(this._renderEffect(effect), content);
}
let _buffer = JSON.parse(JSON.stringify(effect)) as FeatureItem;
const drawByCategory = (buffer: Partial<FeatureItem>) => {
let top: NodeChildren = [], bottom: NodeChildren = [];
switch(buffer.category)
{
case 'value':
const valueVariable = () => typeof buffer.value === 'number' ? numberpicker({ defaultValue: buffer.value, input: (value) => { (buffer as FeatureValue).value = value; summaryText.textContent = textFromEffect(buffer); }, class: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-[80px]' }) : select<`modifier/${MainStat}` | false>([...Object.entries(mainStatShortTexts).map(e => ({ text: 'Mod. de ' + e[1], value: `modifier/${e[0]}` as `modifier/${MainStat}` })), buffer.operation === 'add' ? undefined : { text: 'Interdit', value: false }], { class: { container: 'w-[160px] bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px]' }, defaultValue: buffer.value, change: (value) => { (buffer as FeatureValue).value = value; summaryText.textContent = textFromEffect(buffer); } });
const summaryText = text(textFromEffect(buffer));
let valueSelection = valueVariable();
top = [
select([ (['action', 'reaction'].includes(buffer.property ?? '') ? undefined : { text: '+', value: 'add' }), (['speed', 'capacity', 'action', 'reaction'].includes(buffer.property ?? '') || ['defense/'].some(e => (buffer as FeatureValue).property.startsWith(e))) ? { text: '=', value: 'set' } : undefined ], { defaultValue: buffer.operation, change: (value) => { (buffer as FeatureValue).operation = value as 'add' | 'set'; summaryText.textContent = textFromEffect(buffer); }, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-[80px]' } }),
valueSelection,
tooltip(button(icon('radix-icons:update'), () => {
(buffer as FeatureValue).value = (typeof (buffer as FeatureValue).value === 'number' ? '' as any as false : 0);
const newValueSelection = valueVariable();
valueSelection.replaceWith(newValueSelection);
valueSelection = newValueSelection;
summaryText.textContent = textFromEffect(buffer);
}, 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Changer d\'editeur', 'bottom'),
];
bottom = [ div('px-2 py-1 flex items-center flex-1', [summaryText]) ];
break;
case 'list':
top = [ select([ { text: 'Ajouter', value: 'add' }, { text: 'Supprimer', value: 'remove' } ], { defaultValue: buffer.action, change: (value) => {
(buffer as FeatureList).action = value as 'add' | 'remove';
const element = redraw();
content.replaceWith(element);
content = element;
}, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-32' } }) ];
if(buffer.action === 'add')
{
if(buffer.list === 'spells')
{
bottom = [ combobox(config.spells.map(e => ({ text: e.name, render: () => div('flex flex-col', [ div('flex flex-row justify-between', [ dom('span', { text: e.name, class: 'font-bold' }), div('flex flex-row gap-8', [ dom('span', { class: 'italic', text: `Rang ${e.rank === 4 ? 'spécial' : e.rank}` }), dom('span', { text: spellTypeTexts[e.type] }) ]) ]), div('text-sm text-light-70 dark:text-dark-70', [ text(renderText(e.effect)) ]) ]), value: e.id })), { defaultValue: buffer.item, change: (value) => (buffer as FeatureList).item = value, class: { container: 'bg-light-25 dark:bg-dark-25 hover:z-10 h-[36px] w-full hover:outline-px outline-light-50 dark:outline-dark-50 !border-none' }, fill: 'contain' }) ];
}
else
{
const editor = new MarkdownEditor();
editor.content = getText(buffer.item);
editor.onChange = (item) => (buffer as FeatureList).item = item;
bottom = [ div('px-2 py-1 bg-light-25 dark:bg-dark-25 flex-1 flex items-center', [ editor.dom ]) ];
}
}
else
{
bottom = [ combobox(Object.values(config.features).flatMap(e => e.effect).filter(e => e.category === 'list' && e.list === buffer.list && e.action === 'add').map(e => ({ text: buffer.list !== 'spells' ? renderText(getText((e as Extract<FeatureItem, { category: 'list' }>).item)) : config.spells.find(f => f.id === (e as Extract<FeatureItem, { category: 'list' }>).item)?.name ?? '', value: (e as Extract<FeatureItem, { category: 'list' }>).item })), { defaultValue: buffer.item, change: (item) => buffer.item = item, class: { container: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-full overflow-hidden truncate', option: 'max-h-[90px] text-sm' }, fill: 'contain' }) ];
}
break;
case 'choice':
const availableChoices: Option<Partial<FeatureValue | FeatureList>>[] = featureChoices.filter(e => (e?.value as FeatureItem)?.category !== 'choice').map(e => { if(e) e.value = Array.isArray(e.value) ? e.value.filter(f => (f?.value as FeatureItem)?.category !== 'choice') : e.value; return e; }) as Option<Partial<FeatureValue | FeatureList>>[];
const addChoice = () => {
const choice: { text: string; effects: (Partial<FeatureValue | FeatureList>)[]; } = { effects: [{ id: getID() }], text: '' };
(buffer as FeatureChoice).options.push(choice as FeatureChoice["options"][number]);
return choice;
};
const addEffect = (choice: { text: string; effects: (Partial<FeatureValue | FeatureList>)[] }) => {
const effect: (Partial<FeatureValue | FeatureList>) = { id: getID() };
choice.effects.push(effect);
return effect;
};
const renderEffect = (option: { text: string; effects: (Partial<FeatureValue | FeatureList>)[] }, effect: Partial<FeatureValue | FeatureList>) => {
const { top: _top, bottom: _bottom } = drawByCategory(effect);
let element = div('border border-light-30 dark:border-dark-30 col-span-2 row-span-2', [ div('flex justify-between items-stretch', [
div('flex flex-row flex-1', [
combobox(availableChoices, { defaultValue: match(effect as FeatureItem) as Partial<FeatureValue | FeatureList> | undefined, class: { container: 'bg-light-25 dark:bg-dark-25 w-[300px] -m-px hover:z-10 h-[36px]' }, fill: 'cover', change: (e: Partial<FeatureValue | FeatureList>) => {
const idx = option.effects.findIndex(e => e === effect);
option.effects[idx] = effect = { ...e, id: effect.id };
const _element = renderEffect(option, effect);
element.replaceWith(_element);
element = _element;
} }),
..._top,
]),
div('flex', [ tooltip(button(icon('radix-icons:trash'), () => { option.effects = option.effects.filter(e => e === effect); element.remove(); }, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Supprimer", "bottom") ])
]), div('flex border-t border-light-35 dark:border-dark-35 max-h-[300px] min-h-[36px] overflow-y-auto overflow-x-hidden', _bottom) ]);
return element;
}
const renderOption = (option: { text: string; effects: (Partial<FeatureValue | FeatureList>)[] }, state: boolean) => {
const effects = div('flex flex-col -m-px flex flex-col ms-px ps-8 w-full', option.effects.map(e => renderEffect(option, e)));
let _content = foldable([ effects ], [ div('flex flex-row flex-1 justify-between', [ input('text', { defaultValue: option.text, input: (value) => option.text = value, placeholder: 'Nom de l\'option', class: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] flex-shrink-1' }), div('flex flex-row flex-shrink-1', [ tooltip(button(icon('radix-icons:plus'), () => effects.appendChild(renderEffect(option, addEffect(option))), 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Nouvel effet', 'bottom'), , tooltip(button(icon('radix-icons:trash'), () => {
_content.remove();
(buffer as FeatureChoice).options = (buffer as FeatureChoice).options.filter(e => e !== option);
}, 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Supprimer', 'bottom') ]) ]) ], { class: { title: 'border-b border-light-35 dark:border-dark-35', icon: 'w-[34px] h-[34px]', content: 'border-b border-light-35 dark:border-dark-35' }, open: state });
return _content;
}
const list = div('flex flex-col flex-1 divide-y divide-light-35 dark:divide-dark-35 gap-2', buffer.options?.map(e => renderOption(e, false)) ?? []);
top = [ input('text', { defaultValue: buffer.text, input: (value) => (buffer as FeatureChoice).text = value, class: 'bg-light-25 dark:bg-dark-25 !-m-px hover:z-10 h-[36px] w-full', placeholder: 'Description' }), tooltip(button(icon('radix-icons:plus'), () => list.appendChild(renderOption(addChoice(), true)), 'px-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), 'Nouvelle option', 'bottom') ];
bottom = [ list ];
break;
default: break;
}
return { top, bottom };
}
const redraw = () => {
const { top, bottom } = drawByCategory(_buffer);
return div('border border-light-30 dark:border-dark-30 col-span-2 row-span-2', [ div('flex justify-between items-stretch', [
div('flex flex-row flex-1', [
combobox(featureChoices, { defaultValue: match(_buffer), class: { container: 'bg-light-25 dark:bg-dark-25 w-[300px] -m-px hover:z-10 h-[36px]' }, fill: 'cover', change: (e) => {
_buffer = { id: _buffer.id, ...e } as FeatureItem;
const element = redraw();
content.replaceWith(element);
content = element;
} }),
...top,
const _item = JSON.parse(JSON.stringify(item)) as ItemConfig;
MarkdownEditor.singleton.content = getText(_item.description);
MarkdownEditor.singleton.onChange = (value) => ItemPanel.config.texts[_item.description]!.default = value;
const effectContainer = div('grid grid-cols-2 gap-4 px-2 flex-1', _item.effects?.map(e => new FeatureEditor(_item.effects!, e.id, false).container));
return dom('div', { attributes: { 'data-state': 'inactive' }, class: 'border-light-35 dark:border-dark-35 bg-light-10 dark:bg-dark-10 border-l absolute top-0 bottom-0 right-0 w-[10%] data-[state=active]:w-1/2 flex flex-col gap-2 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]' }, [
div('flex flex-row justify-between items-center', [
tooltip(button(icon('radix-icons:check', { width: 20, height: 20 }), () => {
success!(_item);
MarkdownEditor.singleton.onChange = undefined;
}, 'p-1'), 'Valider', 'left'),
dom('label', { class: 'flex justify-center items-center my-2' }, [
dom('span', { class: 'pb-1 md:p-0', text: "Nom" }),
input('text', { defaultValue: _item.name, input: (v) => _item.name = v })
]),
div('flex', [ tooltip(button(icon('radix-icons:check'), approve, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Valider", "bottom"), tooltip(button(icon('radix-icons:cross-1'), reject, 'p-2 -m-px hover:z-10 border border-light-35 dark:border-dark-35 hover:border-light-50 dark:hover:border-dark-50'), "Annuler", "bottom") ])
]), div('flex border-t border-light-35 dark:border-dark-35 max-h-[300px] min-h-[36px] overflow-y-auto overflow-x-hidden', bottom) ]);
}
let content = redraw();
return content;
tooltip(button(icon('radix-icons:cross-1', { width: 20, height: 20 }), () => {
failure!(item);
MarkdownEditor.singleton.onChange = undefined;
}, 'p-1'), 'Annuler', 'left'),
]),
foldable([
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ checkbox({ defaultValue: _item.weight !== undefined, change: function(value) { _item.weight = value ? 0 : undefined; if(this.parentElement?.parentElement?.children[1]) { (this.parentElement.parentElement.children[1] as Element & { disabled: boolean }).disabled = !value; (this.parentElement.parentElement.children[1] as HTMLInputElement).value = value ? '0' : ''; } this.parentElement?.toggleAttribute('data-disabled', !value) }, class: { container: '!w-4 !h-4' } }), span('', 'Poids'), ]), numberpicker({ defaultValue: _item.weight, disabled: _item.weight === undefined, input: (v) => _item.weight = v, class: '!w-1/3' }), ]),
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ checkbox({ defaultValue: _item.price !== undefined, change: function(value) { _item.price = value ? 0 : undefined; if(this.parentElement?.parentElement?.children[1]) { (this.parentElement.parentElement.children[1] as Element & { disabled: boolean }).disabled = !value; (this.parentElement.parentElement.children[1] as HTMLInputElement).value = value ? '0' : ''; } this.parentElement?.toggleAttribute('data-disabled', !value) }, class: { container: '!w-4 !h-4' } }), span('', 'Prix'), ]), numberpicker({ defaultValue: _item.price, disabled: _item.price === undefined, input: (v) => _item.price = v, class: '!w-1/3' }), ]),
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ checkbox({ defaultValue: _item.capacity !== undefined, change: function(value) { _item.capacity = value ? 0 : undefined; if(this.parentElement?.parentElement?.children[1]) { (this.parentElement.parentElement.children[1] as Element & { disabled: boolean }).disabled = !value; (this.parentElement.parentElement.children[1] as HTMLInputElement).value = value ? '0' : ''; } this.parentElement?.toggleAttribute('data-disabled', !value) }, class: { container: '!w-4 !h-4' } }), span('', 'Capacité magique'), ]), numberpicker({ defaultValue: _item.capacity, disabled: _item.capacity === undefined, input: (v) => _item.capacity = v, class: '!w-1/3' }), ]),
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ checkbox({ defaultValue: _item.powercost !== undefined, change: function(value) { _item.powercost = value ? 0 : undefined; if(this.parentElement?.parentElement?.children[1]) { (this.parentElement.parentElement.children[1] as Element & { disabled: boolean }).disabled = !value; (this.parentElement.parentElement.children[1] as HTMLInputElement).value = value ? '0' : ''; } this.parentElement?.toggleAttribute('data-disabled', !value) }, class: { container: '!w-4 !h-4' } }), span('', 'Puissance magique'), ]), numberpicker({ defaultValue: _item.powercost, disabled: _item.powercost === undefined, input: (v) => _item.powercost = v, class: '!w-1/3' }), ]),
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ checkbox({ defaultValue: _item.equippable, change: function(value) { _item.equippable = value; this.parentElement?.toggleAttribute('data-disabled', !value) }, class: { container: '!w-4 !h-4' } }), span('', 'Equipable'), ]), div('flex flex-row gap-2 items-center mx-4', [ checkbox({ defaultValue: _item.consummable, change: function(value) { _item.consummable = value; this.parentElement?.toggleAttribute('data-disabled', !value) }, class: { container: '!w-4 !h-4' } }), span('', 'Consommable'), ]) ]),
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ checkbox({ defaultValue: _item.charge !== undefined, change: function(value) { _item.charge = value ? 0 : undefined; if(this.parentElement?.parentElement?.children[1]) { (this.parentElement.parentElement.children[1] as Element & { disabled: boolean }).disabled = !value; (this.parentElement.parentElement.children[1] as HTMLInputElement).value = value ? '0' : ''; } this.parentElement?.toggleAttribute('data-disabled', !value) }, class: { container: '!w-4 !h-4' } }), span('', 'Charges'), ]), numberpicker({ defaultValue: _item.charge, disabled: _item.charge === undefined, input: (v) => _item.charge = v, class: '!w-1/3' }), ]),
], [ span('text-lg font-bold', "Propriétés"), div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ span('', 'Rareté'), ]), select(Object.keys(rarityText).map(e => ({ text: rarityText[e as Rarity], value: e as Rarity })), { defaultValue: _item.rarity, change: (v) => _item.rarity = v, class: { container: '!w-1/2' } }), ]) ], { class: { content: 'group-data-[active]:grid grid-cols-2 my-2 gap-4', title: 'grid grid-cols-2 gap-4 mx-2', container: 'pb-2 border-b border-light-35 dark:border-dark-35' }, open: true } ),
_item.category === 'armor' ? foldable([
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ span('', 'Type'), ]), select<'light' | 'medium' | 'heavy'>([{ text: 'Armure légère', value: 'light' }, { text: 'Armure moyenne', value: 'medium' }, { text: 'Armure lourde', value: 'heavy' }], { defaultValue: _item.type, change: (v) => _item.type = v, class: { container: '!w-1/2' } }), ]),
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ span('', 'Durabilité'), ]), numberpicker({ defaultValue: _item.health, input: (v) => _item.health = v, class: '!w-1/3' }), ]),
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ span('', 'Absorbtion (fixe)'), ]), numberpicker({ defaultValue: _item.absorb.static, input: (v) => _item.absorb.static = v, class: '!w-1/3' }), ]),
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ span('', 'Absorbtion (%)'), ]), numberpicker({ defaultValue: _item.absorb.percent, input: (v) => _item.absorb.percent = v, class: '!w-1/3' }), ]),
], [ span('text-lg font-bold', "Armure") ], { class: { content: 'group-data-[active]:grid grid-cols-2 my-2 gap-4', title: 'grid grid-cols-2 gap-4 mx-2', container: 'pb-2 border-b border-light-35 dark:border-dark-35' }, open: true } ) : undefined,
_item.category === 'weapon' ? foldable([
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ span('', 'Type de dégâts'), ]), select(Object.keys(damageTypeTexts).map(e => ({ text: damageTypeTexts[e as DamageType], value: e as DamageType })), { defaultValue: _item.damage.type, change: (v) => _item.damage.type = v, class: { container: '!w-1/3' } }), ]),
div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ span('', 'Dégats'), ]), input('text', { defaultValue: _item.damage.value, input: (v) => _item.damage.value = v, class: '!w-1/3' }), ]),
], [ span('text-lg font-bold', "Propriétés"), div('flex flex-row gap-2 items-center justify-between', [ div('flex flex-row gap-2 items-center', [ span('', 'Rareté'), ]), multiselect(Object.keys(weaponTypeTexts).map(e => ({ text: weaponTypeTexts[e as WeaponType], value: e as WeaponType })), { defaultValue: _item.type, change: (v) => _item.type = v, class: { container: '!w-1/2' } }), ]) ], { class: { content: 'group-data-[active]:grid grid-cols-2 my-2 gap-4', title: 'grid grid-cols-2 gap-4 mx-2', container: 'pb-2 border-b border-light-35 dark:border-dark-35' }, open: true } ) : undefined,
foldable([div('p-1 border border-light-40 dark:border-dark-40 w-full bg-light-25 dark:bg-dark-25 min-h-48 max-h-[32rem]', [ MarkdownEditor.singleton.dom ])], [ span('text-lg font-bold px-2', "Description") ], { class: { container: 'gap-4 pb-2 border-b border-light-35 dark:border-dark-35' }, open: true, }),
foldable([ effectContainer ], [ dom('h3', { class: 'text-lg font-bold', text: 'Effets' }),
tooltip(button(icon('radix-icons:plus', { width: 20, height: 20 }), () => {
const f = { id: getID(), };
_item.effects ??= [];
//@ts-expect-error
_item.effects.push(f);
effectContainer.appendChild(new FeatureEditor(_item.effects, f.id, true).container);
}, 'p-1 hidden group-data-[active]:block'), 'Ajouter', 'left'),
], { class: { container: 'flex flex-col gap-2 w-full', title: 'flex flex-row justify-between px-2' } })
]);
}
get container()
static edit(item: ItemConfig): Promise<ItemConfig>
{
return this._container;
let container: HTMLElement, close: Function;
return new Promise<ItemConfig>((success, failure) => {
container = ItemPanel.render(item, success, failure);
close = fullblocker([container], {
priority: true, closeWhenOutside: false,
}).close;
setTimeout(() => container.setAttribute('data-state', 'active'), 1);
}).finally(() => {
setTimeout(close, 150);
container.setAttribute('data-state', 'inactive');
});
}
}
const featureChoices: Option<Partial<FeatureItem>>[] = [
const featureChoices: Option<Partial<FeatureOption>>[] = [
{ text: 'PV max', value: { category: 'value', property: 'health', operation: 'add', value: 1 }, },
{ text: 'Mana max', value: { category: 'value', property: 'mana', operation: 'add', value: 1 }, },
{ text: 'Nombre de sorts maitrisés', value: { category: 'value', property: 'spellslots', operation: 'add', value: 1 }, },
@@ -634,8 +890,8 @@ const featureChoices: Option<Partial<FeatureItem>>[] = [
{ text: 'Passif', value: { category: 'list', list: 'passive', action: 'add' }, },
{ text: 'Choix', value: { category: 'choice', text: '', options: [] }, },
];
const flattenFeatureChoices = Tree.accumulate(featureChoices, 'value', (item) => Array.isArray(item.value) ? undefined : item.value).filter(e => !!e) as Partial<FeatureItem>[];
function textFromEffect(effect: Partial<FeatureItem | FeatureEquipment>): string
const flattenFeatureChoices = Tree.accumulate(featureChoices, 'value', (item) => Array.isArray(item.value) ? undefined : item.value).filter(e => !!e) as Partial<FeatureOption>[];
function textFromEffect(effect: Partial<FeatureOption>): string
{
if(effect.category === 'value')
{
@@ -655,7 +911,7 @@ function textFromEffect(effect: Partial<FeatureItem | FeatureEquipment>): string
case 'speed':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' case(s) de course.' }, falsely: '+0 cases de course' }) : textFromValue(effect.value, { prefix: { truely: 'Vitesse de course de ' }, suffix: { truely: ' case(s).' }, falsely: 'Déplacement impossible.' });
case 'capacity':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' unité(s) d\'quipement.' } }) : textFromValue(effect.value, { prefix: { truely: 'Capacité d\'equipement fixé à ' }, suffix: { truely: ' unité(s).' }, falsely: 'Impossible de posséder du materiel.' });
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' unité(s) d\'quipement.' } }) : textFromValue(effect.value, { prefix: { truely: 'Capacité d\'équipement fixé à ' }, suffix: { truely: ' unité(s).' }, falsely: 'Impossible de posséder du materiel.' });
case 'initiative':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ' à l\'itiniative.' } }) : textFromValue(effect.value, { prefix: { truely: 'Initiative fixé à ' }, suffix: { truely: '.' }, falsely: 'Opération interdite (Initiative = interdit).' });
case 'training':
@@ -735,7 +991,7 @@ function textFromEffect(effect: Partial<FeatureItem | FeatureEquipment>): string
switch(splited[1])
{
case 'defense':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ` aux jets de résistance de ${resistanceTexts[splited[2] as Resistance]}.` } }) : textFromValue(effect.value, { prefix: { truely: `Jets de résistance de ${resistanceTexts[splited[2] as Resistance]} = ` }, suffix: { truely: '.' }, falsely: `Opération interdite (Résistance ${resistanceTexts[splited[2] as Resistance]} = interdit).` });
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { positive: '+', text: '+Mod. de ' }, suffix: { truely: ` aux jets de résistance de ${mainStatTexts[splited[2] as MainStat]}.` } }) : textFromValue(effect.value, { prefix: { truely: `Jets de résistance de ${mainStatTexts[splited[2] as MainStat]} = ` }, suffix: { truely: '.' }, falsely: `Opération interdite (Résistance ${mainStatTexts[splited[2] as MainStat]} = interdit).` });
case 'abilities':
return effect.operation === 'add' ? textFromValue(effect.value, { prefix: { truely: `Max de ${abilityTexts[splited[2] as Ability]} `, positive: '+', text: '+Mod. de ' }, suffix: { truely: '.' } }) : effect.operation === 'set' ? textFromValue(effect.value, { prefix: { truely: `Max de ${abilityTexts[splited[2] as Ability]} fixé à ` }, suffix: { truely: '.' }, falsely: `Opération interdite ( ${abilityTexts[splited[2] as Ability]} max = interdit).` }) : textFromValue(effect.value, { prefix: { truely: `Max de ${abilityTexts[splited[2] as Ability]} min à ` }, suffix: { truely: '.' }, falsely: `Opération interdite ( ${abilityTexts[splited[2] as Ability]} max = interdit).` });
default: return 'Bonus inconnu';
@@ -756,13 +1012,13 @@ function textFromEffect(effect: Partial<FeatureItem | FeatureEquipment>): string
switch(effect.list)
{
case 'action':
return effect.action === 'add' ? effect.item ? getText((config.action[effect.item]?.description) ?? 'Inconnu') : 'Inconnu' : `Suppression de l'action "${effect.item ? (config.action[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"`;
return effect.action === 'add' ? `Gain de l'action "${effect.item ? (config.action[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"` : `Suppression de l'action "${effect.item ? (config.action[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"`;
case 'reaction':
return effect.action === 'add' ? effect.item ? getText((config.reaction[effect.item]?.description ?? 'Inconnu')) : 'Inconnu' : `Suppression de la réaction "${effect.item ? (config.reaction[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"`;
return effect.action === 'add' ? `Gain de la réaction "${effect.item ? (config.reaction[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"` : `Suppression de la réaction "${effect.item ? (config.reaction[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"`;
case 'freeaction':
return effect.action === 'add' ? effect.item ? getText((config.freeaction[effect.item]?.description ?? 'Inconnu')) : 'Inconnu' : `Suppression de l'action libre "${effect.item ? (config.freeaction[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"`;
return effect.action === 'add' ? `Gain de l'action libre "${effect.item ? (config.freeaction[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"` : `Suppression de l'action libre "${effect.item ? (config.freeaction[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"`;
case 'passive':
return effect.action === 'add' ? effect.item ? getText((config.passive[effect.item]?.description ?? 'Inconnu')) : 'Inconnu' : `Suppression du passif "${effect.item ? (config.passive[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"`;
return effect.action === 'add' ? `Gain du passif "${effect.item ? (config.passive[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"` : `Suppression du passif "${effect.item ? (config.passive[effect.item]?.name ?? 'Inconnu') : 'Inconnu'}"`;
case 'spells':
return effect.action === 'add' ? `Maitrise du sort "${config.spells.find(e => e.id === effect.item)?.name ?? 'Sort inconnu'}".` : `Perte de maitrise du sort "${config.spells.find(e => e.id === effect.item)?.name ?? 'Sort inconnu'}".`;
case 'sickness':

View File

@@ -17,13 +17,17 @@ export interface FollowerProperties extends FloatingProperties
blur?: () => void;
priority?: boolean;
}
export type FloatState = 'shown' | 'showing' | 'hidden' | 'hiding' | 'pinned';
export interface PopperProperties extends FloatingProperties
{
content?: NodeChildren | (() => NodeChildren);
delay?: number;
onShow?: () => boolean | void;
onHide?: () => boolean | void;
events?: {
show: Array<keyof HTMLElementEventMap>;
hide: Array<keyof HTMLElementEventMap>;
onshow?: (state: FloatState) => boolean;
onhide?: (state: FloatState) => boolean;
};
}
export interface ModalProperties
@@ -36,20 +40,21 @@ export interface ModalProperties
let teleport: HTMLDivElement;
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);
}
export function popper(container: HTMLElement, properties?: PopperProperties): HTMLElement
export function popper(container: HTMLElement, properties?: PopperProperties)
{
let shown = 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 content = dom('div', { class: ['fixed hidden', properties?.class], style: properties?.style, attributes: { 'data-state': 'closed' } });
let state: FloatState = 'hidden', manualStop = false, timeout: Timer;
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: 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';
function update()
{
FloatingUI.computePosition(container, content, {
FloatingUI.computePosition(container, floater, {
placement: properties?.placement,
strategy: 'fixed',
middleware: [
@@ -59,14 +64,14 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
FloatingUI.flip({ rootBoundary: rect }),
properties?.cover && properties?.cover !== 'none' && FloatingUI.size({ rootBoundary: rect, apply: ({ availableWidth, availableHeight }) => {
if(properties?.cover === 'width' || properties?.cover === 'all')
content.style.width = `${availableWidth}px`;
floater.style.maxWidth = `${availableWidth}px`;
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,
]
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(content.style, {
Object.assign(floater.style, {
left: `${x}px`,
top: `${y}px`,
visibility: middlewareData.hide?.referenceHidden || middlewareData.hide?.escaped ? 'hidden' : 'visible',
@@ -74,7 +79,7 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
const side = placement.split('-')[0] as FloatingUI.Side;
content.setAttribute('data-side', side);
floater.setAttribute('data-side', side);
if(middlewareData.arrow)
{
@@ -85,53 +90,55 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
right: 'left',
bottom: 'top',
left: 'right',
}[side]!;
}[side]!;
const rotation = {
const rotation = {
top: "0",
bottom: "180",
left: "270",
right: "90"
}[side]!;
}[side]!;
Object.assign(arrow.style, {
Object.assign(arrow.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: `-8px`,
[staticSide]: `-7px`,
transform: `rotate(${rotation}deg)`,
});
});
}
});
}
let stop: () => void | undefined;
let _stop: () => void | undefined, empty = true;
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')
{
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);
state = 'showing';
timeout = setTimeout(() => {
if(!shown)
if(state !== 'shown')
{
teleport!.appendChild(content);
teleport!.appendChild(floater);
content.setAttribute('data-state', 'open');
content.classList.toggle('hidden', false);
floater.setAttribute('data-state', 'open');
floater.classList.toggle('hidden', false);
update();
stop = FloatingUI.autoUpdate(container, content, update, {
_stop && _stop();
_stop = FloatingUI.autoUpdate(container, floater, update, {
animationFrame: true,
layoutShift: false,
elementResize: false,
@@ -139,42 +146,93 @@ export function popper(container: HTMLElement, properties?: PopperProperties): H
ancestorResize: false,
});
}
shown = true;
state = 'shown';
}, properties?.delay ?? 0);
}
}
function hide()
{
if(!properties?.onHide || properties?.onHide() !== false)
if(state !== 'hiding' && state !== 'pinned' && (!properties?.events?.onhide || properties?.events?.onhide(state) !== false))
{
clearTimeout(timeout);
state = 'hiding';
timeout = setTimeout(() => {
content.remove();
stop && stop();
floater.remove();
_stop && _stop();
shown = false;
}, shown ? properties?.delay ?? 0 : 0);
floater.setAttribute('data-state', 'closed');
floater.classList.toggle('hidden', true);
state = 'hidden';
}, properties?.delay ?? 0);
}
}
function link(element: HTMLElement) {
Object.entries({
'mouseenter': show,
'mousemove': show,
'mouseleave': hide,
'focus': show,
'blur': hide,
} as Record<keyof HTMLElementEventMap, () => void>).forEach(([event, listener]) => {
element.addEventListener(event, listener);
function start()
{
state = 'hidden';
floater.toggleAttribute('data-pinned', false);
update();
_stop && _stop();
_stop = FloatingUI.autoUpdate(container, floater, update, {
animationFrame: true,
layoutShift: false,
elementResize: false,
ancestorScroll: false,
ancestorResize: false,
});
}
link(container);
link(content);
function stop()
{
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)
{
@@ -290,14 +348,17 @@ export function tooltip(container: HTMLElement, txt: string | Text, placement: F
arrow: true,
offset: 8,
delay: delay,
content: [ typeof txt === 'string' ? text(txt) : txt ],
content: () => [ typeof txt === 'string' ? text(txt) : txt ],
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)
{
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]);

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 { headingRank } from "hast-util-heading-rank";
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
{
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);
}
@@ -43,9 +43,9 @@ export interface MDProperties
style?: string | Record<string, string>;
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)
{
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)
{
let end = start;
const rank = headingRank(data.children[start])!;
const rank = headingRank(data.children[start]!)!;
while(end < data.children.length)
{
end++;
if(heading(data.children[end]) && headingRank(data.children[end])! <= rank)
if(heading(data.children[end]) && headingRank(data.children[end]!)! <= rank)
break;
}
data = { ...data, children: data.children.slice(start, end) };
@@ -70,4 +70,9 @@ export default function(content: string, filter?: string, properties?: MDPropert
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, type Class } from "#shared/dom.util";
import { parseURL } from 'ufo';
import render from "#shared/markdown.util";
import { popper } from "#shared/floating.util";
import { Canvas } from "#shared/canvas.util";
import { Content, iconByType, type LocalContent } from "#shared/content.util";
import { parsePath, unifySlug } from "#shared/general.util";
import { async, loading } from "./components.util";
import { unifySlug } from "#shared/general.util";
import { async, floater } from "./components.util";
import type { FloatState } from "./floating.util";
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 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) => {
e.preventDefault();
router.push(link);
@@ -32,80 +32,55 @@ export const a: Prose = {
])
]);
if(!!overview)
{
popper(el, {
arrow: true,
delay: 150,
offset: 12,
cover: "height",
placement: 'bottom-start',
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]',
content: () => {
return [async('large', Content.getContent(overview.id).then((_content) => {
if(_content?.type === 'markdown')
{
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;
return !!overview ? floater(element, () => [async('large', Content.getContent(overview.id).then((_content) => {
if(_content?.type === 'markdown')
{
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] group-data-[pinned]:h-full group-data-[pinned]:w-full h-[600px] relative w-[600px] relative' }, [canvas.container]);
}
return div('');
})).current], { position: 'bottom-start', pinned: false, title: properties?.label, href: nav.href }) : element;
}
}
export const preview: Prose = {
custom(properties: { href: string, class?: Class, size?: 'small' | 'large' }, children) {
custom(properties: { href: string, class?: Class, label: string }, children) {
const href = properties.href as string;
const { hash, pathname } = parseURL(href);
const router = useRouter();
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 ?? []),
overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined
]);
const magicKeys = useMagicKeys();
return overview ? popper(el, {
arrow: true,
delay: 150,
offset: 12,
cover: "height",
placement: 'bottom-start',
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 w-full z-[45]',
{ 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px]': !properties?.size || properties.size === 'large', 'max-w-[400px] max-h-[250px]': properties.size === 'small' }
],
content: () => {
return [async('large', Content.getContent(overview.id).then((_content) => {
if(_content?.type === 'markdown')
{
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('');
}))];
},
onShow() {
if(!magicKeys.current.has('control') || magicKeys.current.has('meta'))
return false;
},
}) : el;
return !!overview ? floater(element, () => [async('large', Content.getContent(overview.id).then((_content) => {
if(_content?.type === 'markdown')
{
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] group-data-[pinned]:h-full group-data-[pinned]:w-full h-[600px] relative w-[600px] relative' }, [canvas.container]);
}
return div();
})).current], { position: 'bottom-start', pinned: false,
events: {
show: ['mouseenter', 'mousemove'],
hide: ['mouseleave'],
onshow(state: FloatState) {
return state === 'shown' || state === 'hiding' || magicKeys.current.has('control') || magicKeys.current.has('meta');
}
}, title: properties?.label, href: { name: 'explore-path', params: { path: overview.path }, hash: hash } }) : element;
}
}
export const callout: Prose = {

34
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 { Localized } from "#shared/general";
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 "../types/general";
export type MainStat = typeof MAIN_STATS[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 Alignment = typeof ALIGNMENTS[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 i18nID = string;
@@ -82,36 +84,38 @@ export type EnchantementConfig = {
export type ItemConfig = CommonItemConfig & (ArmorConfig | WeaponConfig | WondrousConfig | MundaneConfig);
type CommonItemConfig = {
id: string;
name: string; //TODO -> TextID
description: i18nID;
rarity: 'common' | 'uncommon' | 'rare' | 'legendary';
weight?: 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
enchantments?: string[]; //Enchantment ID
effects?: Array<FeatureValue | FeatureEquipment | FeatureList>;
equippable: boolean;
consummable: boolean;
}
type ArmorConfig = {
category: 'armor';
name: string; //TODO -> TextID
description: i18nID;
health: number;
type: 'light' | 'medium' | 'heavy';
absorb: { static: number, percent: number };
};
type WeaponConfig = {
category: 'weapon';
name: string; //TODO -> TextID
description: i18nID;
damage: string; //Dice formula
type: Array<WeaponType>;
damage: {
value: string; //Dice formula
type: DamageType;
};
};
type WondrousConfig = {
category: 'wondrous';
name: string; //TODO -> TextID
description: i18nID;
effect: FeatureItem[];
};
type MundaneConfig = {
category: 'mundane';
name: string; //TODO -> TextID
description: i18nID;
};
export type SpellConfig = {
id: string;
@@ -121,7 +125,7 @@ export type SpellConfig = {
cost: number;
speed: "action" | "reaction" | number;
elements: Array<SpellElement>;
effect: string; //TODO -> TextID
description: string; //TODO -> TextID
concentration: boolean;
range: 'personnal' | number;
tags?: string[];
@@ -156,7 +160,7 @@ export type FeatureEquipment = {
id: FeatureID;
category: "value";
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;
}
export type FeatureList = {