21 Commits

Author SHA1 Message Date
Clément Pons
25bd165f1d Merge branch 'dev' into HEAD 2025-10-21 17:26:16 +02:00
Clément Pons
5c1f41b0b7 Fix ProseH remains, rollback layout rendering and add proper scrolling to the character sheet tabs 2025-10-21 17:22:46 +02:00
feb2fb56c6 New default layout without vuejs rendering (still needs some fixes) 2025-10-19 23:35:11 +02:00
Clément Pons
df9ae95890 Note tab in character sheet 2025-10-15 17:01:23 +02:00
Clément Pons
72843f2425 Fix registration email and add no character friendly messages 2025-10-15 14:58:59 +02:00
Clément Pons
443612cc58 Floater pinned true handler, SQL schema update to handle private/public notes on character, fix Canvas zoom debounce on move. 2025-10-15 14:34:12 +02:00
Clément Pons
a577e3ccfc Checkbox and item panel improvements 2025-10-14 17:57:34 +02:00
Clément Pons
48e767944a Progress on ItemEditor interface and rendering 2025-10-13 17:56:22 +02:00
d187957915 Start implementing ItemEditor 2025-10-13 13:19:50 +02:00
Clément Pons
16cc3ee438 Floater imrprovement with parametrable show and hide events, title and minimization. 2025-10-10 16:57:36 +02:00
Clément Pons
26aa0847d9 Fix comrpessing bug on null buffers, make pinned floaters resizable and optimize a few things here and there 2025-10-06 17:42:16 +02:00
b19d2d1b41 Updated legal stuff, added floating popup that can be pin and move. Fix character compiler modifier updates not dirtying all dependents. 2025-10-05 23:54:37 +02:00
Clément Pons
89c4476ffb Merge branch 'dev' of https://git.peaceultime.com/Peaceultime/obsidian-visualiser into dev 2025-10-01 17:59:30 +02:00
Clément Pons
3113d8b0f3 Feature choice UI rework, feature editor fixes, new character manage page UI with tabgroup and action config 2025-10-01 17:59:14 +02:00
2b39f26722 Merge branch 'dev' of https://git.peaceultime.com/peaceultime/obsidian-visualiser into dev 2025-09-30 21:50:59 +02:00
d2a807694b Fix compression error 2025-09-30 21:36:40 +02:00
Clément Pons
eb0c33deae New ability display, sereval Character compile and creation fixes 2025-09-30 18:03:38 +02:00
Clément Pons
61d2d144b7 Spell UI, variables saving and mail server fixes (finally working in prod !!!) 2025-09-30 17:15:49 +02:00
Clément Pons
1642cd513f Work in progress: CharacterSheet implementation and FeatureChoice rework 2025-09-29 17:53:41 +02:00
Clément Pons
b1ac379f1a Work in progress: CharacterSheet implementation and FeatureChoice rework 2025-09-29 17:53:39 +02:00
81f191d5f6 Compress middleware 2025-09-14 20:46:48 +02:00
53 changed files with 4605 additions and 1985 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, { fakeA } 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', fakeA, [ 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": []}'),
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,26 +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 } 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, defaultCharacter, elementTexts, spellTypeTexts } from '#shared/character.util';
import { getText } from '#shared/i18n';
import { fakeA } 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';
const config = characterConfig as CharacterConfig;
const id = useRouter().currentRoute.value.params.id;
const { user } = useUserSession();
const { data, status, error } = await useFetch(`/api/character/${id}`);
const compiler = new CharacterCompiler(data.value ?? defaultCharacter);
const character = ref<CompiledCharacter>(compiler.compiled);
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
@@ -33,241 +15,23 @@ text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yel
text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-purple bg-light-purple dark:bg-dark-purple
*/
function openSpellPanel() {
const availableSpells = Object.values(config.spells).filter(spell => {
if (spell.rank === 4) return false;
if (character.value.spellranks[spell.type] < spell.rank) return false;
return true;
});
const config = characterConfig as CharacterConfig;
const textAmount = text(character.value.variables.spells.length.toString()), textMax = text(character.value.spellslots.toString());
const container = div("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-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [
div("flex flex-row justify-between items-center mb-4", [
dom("h2", { class: "text-xl font-bold", text: "Ajouter un sort" }),
div('flex flex-row gap-4 items-center', [ dom('span', { class: 'italic text-light-70 dark:text-dark-70 text-sm' }, [ textAmount, text(' / '), textMax, text(' sorts maitrisés') ]), tooltip(button(icon("radix-icons:cross-1", { width: 20, height: 20 }), () => {
setTimeout(blocker.close, 150);
container.setAttribute('data-state', 'inactive');
}, "p-1"), "Fermer", "left") ])
]),
div('flex flex-col divide-y *:py-2 -my-2 overflow-y-auto', availableSpells.map(spell => {
let state = character.value.lists.spells?.includes(spell.id) ? 'given' : character.value.variables.spells.includes(spell.id) ? 'choosen' : 'empty';
const toggleText = text(state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'), toggleButton = button(toggleText, () => {
if(state === 'choosen')
{
compiler.variable('spells', character.value.variables.spells.filter(e => e !== spell.id));
state = 'empty';
}
else if(state === 'empty')
{
compiler.variable('spells', [...character.value.variables.spells, spell.id]);
state = 'choosen';
}
character.value = compiler.compiled;
toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter';
textAmount.textContent = character.value.variables.spells.length.toString();
}, "px-2 py-1 text-sm font-normal");
toggleButton.disabled = state === 'given';
return foldable(() => [
markdown(spell.effect),
], [ 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", [
div("flex flex-row text-sm gap-2",
spell.elements.map(el =>
dom("span", {
class: [`border !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px`, elementTexts[el].class],
text: elementTexts[el].text
})
)
),
div("flex flex-row text-sm gap-1", [
...(spell.rank !== 4 ? [
dom("span", { text: `Rang ${spell.rank}` }),
text("/"),
dom("span", { text: spellTypeTexts[spell.type] }),
text("/")
] : []),
dom("span", { text: `${spell.cost} mana` }),
text("/"),
dom("span", { text: typeof spell.speed === "string" ? spell.speed : `${spell.speed} minutes` })
]),
toggleButton,
]),
]) ], { open: false, class: { container: "px-2 flex flex-col border-light-35 dark:border-dark-35", content: 'py-2' } });
}))
]);
const blocker = fullblocker([ container ], { closeWhenOutside: true });
setTimeout(() => container.setAttribute('data-state', 'active'), 1);
}
const id = useRouter().currentRoute.value.params.id ? unifySlug(useRouter().currentRoute.value.params.id!) : undefined;
const { user } = useUserSession();
const container = useTemplateRef('container');
onMounted(() => {
queueMicrotask(() => {
if(container.value && id)
{
const character = new CharacterSheet(id, user);
container.value.replaceWith(character.container);
}
});
});
</script>
<template>
<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-col lg:border-l border-light-30 dark:border-dark-30 py-4 ps-4">
<span class="flex flex-row items-center gap-2">PV: {{ character.health - character.variables.health }}/{{ character.health }}</span>
<span class="flex flex-row items-center gap-2">Mana: {{ character.mana - character.variables.mana }}/{{ 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="grid 2xl:grid-cols-10 grid-cols-1 gap-4 items-center border-b border-light-30 dark:border-dark-30 me-4 pe-4">
<div class="flex relative justify-between ps-4 gap-2 2xl:col-span-6">
<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 2xl:border-l border-light-30 dark:border-dark-30 ps-4 2xl:col-span-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>
<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: fakeA } }" />
</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: fakeA } }" />
</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: fakeA } }" />
</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: fakeA } }" />
</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">
<div class="flex flex-1 flex-col ps-8 gap-4 py-8">
<MarkdownRenderer :content="character.notes" />
</div>
</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

@@ -1,5 +1,8 @@
<script setup lang="ts">
import characterConfig from '#shared/character-config.json';
import type { CharacterConfig } from '~/types/character';
const { data: characters, error, status } = await useFetch(`/api/character`, { params: { visibility: "public" } });
const config = characterConfig as CharacterConfig;
</script>
<template>
@@ -10,15 +13,34 @@ const { data: characters, error, status } = await useFetch(`/api/character`, { p
<div v-if="status === 'pending'" class="flex flex-1 justify-center align-center">
<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="border border-light-30 dark:border-dark-30 p-3 flex flex-row gap-4" v-for="character of characters">
<Avatar size="large" icon="radix-icons:person" src="" />
<div class="flex flex-1 flex-shrink flex-col truncate">
<NuxtLink class="text-xl font-bold hover:text-accent-blue truncate" :to="{ name: 'character-id', params: { id: character.id } }" :title="character.name">{{ character.name }}</NuxtLink>
<span class="text-sm truncate">Niveau {{ character.level }}</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>
</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

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

View File

@@ -5,7 +5,6 @@ import { usersDataTable, usersTable } from '~/db/schema';
import { schema } from '~/schemas/registration';
import { 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

@@ -1,9 +1,9 @@
import { and, eq, SQL, sql, type Operators } from 'drizzle-orm';
import { eq, SQL, type Operators } from 'drizzle-orm';
import useDatabase from '~/composables/useDatabase';
import { characterTable, userPermissionsTable } from '~/db/schema';
import { hasPermissions } from '~/shared/auth.util';
import { group } from '~/shared/general.util';
import type { Character, Level, MainStat, TrainingLevel } from '~/types/character';
import type { Character, MainStat, TrainingLevel } from '~/types/character';
export default defineEventHandler(async (e) => {
let { visibility } = getQuery(e) as { visibility?: "public" | "own" | "admin" };
@@ -16,16 +16,16 @@ export default defineEventHandler(async (e) => {
let where: ((character: typeof characterTable._.config.columns, sql: Operators) => SQL | undefined) | undefined = undefined;
const db = useDatabase();
const session = await getUserSession(e);
if(visibility === "own")
{
const session = await getUserSession(e);
if(!session.user)
{
setResponseStatus(e, 401);
return;
}
where = (character, { eq, and }) => and(eq(character.owner, session.user!.id), eq(character.visibility, "private"));
where = (character, { eq, and }) => and(eq(character.owner, session.user!.id));
}
else if(visibility === 'public')
{
@@ -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,18 +31,19 @@ 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,
}).returning({ id: characterTable.id }).get().id;
if(Object.keys(body.data.leveling).length > 0) tx.insert(characterLevelingTable).values(Object.entries(body.data.leveling).map(e => ({ character: id, level: parseInt(e[0], 10), choice: e[1]! }))).run();
if(Object.keys(body.data.leveling).length > 0) tx.insert(characterLevelingTable).values(Object.entries(body.data.leveling).filter(e => e[1] !== undefined).map(e => ({ character: id, level: parseInt(e[0], 10), choice: e[1]! }))).run();
const training = Object.entries(body.data.training).flatMap(e => Object.entries(e[1]).map(_e => ({ character: id, stat: e[0] as MainStat, level: parseInt(_e[0], 10), choice: _e[1]! })));
const training = Object.entries(body.data.training).flatMap(e => Object.entries(e[1]).filter(e => e[1] !== undefined).map(_e => ({ character: id, stat: e[0] as MainStat, level: parseInt(_e[0], 10), choice: _e[1]! })));
if(training.length > 0) tx.insert(characterTrainingTable).values(training).run();
const abilities = Object.entries(body.data.abilities).map(e => ({ character: id, ability: e[0] as Ability, value: e[1] }));
const abilities = Object.entries(body.data.abilities).filter(e => e[1] !== undefined).map(e => ({ character: id, ability: e[0] as Ability, value: e[1] }));
if(abilities.length > 0) tx.insert(characterAbilitiesTable).values(abilities).run();
return id;

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { fakeA } from "#shared/proses";
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();
@@ -189,7 +253,7 @@ export class NodeEditable extends Node
this.nodeDom = dom('div', { class: ['absolute group', {'-z-10': this.properties.type === 'group', 'z-10': this.properties.type !== 'group'}], style: { transform: `translate(${this.properties.x}px, ${this.properties.y}px)`, width: `${this.properties.width}px`, height: `${this.properties.height}px`, '--canvas-color': this.properties.color?.hex } }, [
dom('div', { class: ['outline-0 transition-[outline-width] border-2 bg-light-20 dark:bg-dark-20 w-full h-full group-hover:outline-4', style.border, style.outline] }, [
dom('div', { class: ['w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto', style.bg], listeners: this.properties.type === 'text' ? { mouseenter: e => this.dispatchEvent(new CustomEvent('focus', { detail: this })), click: e => this.dispatchEvent(new CustomEvent('select', { detail: this })), dblclick: e => this.dispatchEvent(new CustomEvent('edit', { detail: this })) } : undefined }, [this.properties.text ? dom('div', { class: 'flex items-center' }, [render(this.properties.text, undefined, { tags: { a: fakeA } })]) : undefined])
dom('div', { class: ['w-full h-full py-2 px-4 flex !bg-opacity-[0.07] overflow-auto', style.bg], listeners: this.properties.type === 'text' ? { mouseenter: e => this.dispatchEvent(new CustomEvent('focus', { detail: this })), click: e => this.dispatchEvent(new CustomEvent('select', { detail: this })), dblclick: e => this.dispatchEvent(new CustomEvent('edit', { detail: this })) } : undefined }, [this.properties.text ? dom('div', { class: 'flex items-center' }, [render(this.properties.text, undefined, { tags: { a: preview } })]) : undefined])
])
]);
@@ -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 it is too large Load Diff

View File

@@ -1,12 +1,15 @@
import type { Ability, Alignment, Character, CharacterConfig, CharacterVariables, CompiledCharacter, FeatureItem, Level, MainStat, Resistance, SpellElement, SpellType, TrainingLevel } from "~/types/character";
import type { Ability, Alignment, Character, CharacterConfig, CharacterVariables, CompiledCharacter, DamageType, FeatureItem, Level, MainStat, Resistance, SpellConfig, SpellElement, SpellType, TrainingLevel, WeaponType } from "~/types/character";
import { z } from "zod/v4";
import characterConfig from '#shared/character-config.json';
import { fakeA } from "#shared/proses";
import { button, input, loading, numberpicker, select, Toaster, toggle } from "#shared/components.util";
import { div, dom, icon, text } from "#shared/dom.util";
import { followermenu, tooltip } from "#shared/floating.util";
import proses, { preview } from "#shared/proses";
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 markdownUtil from "#shared/markdown.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;
@@ -19,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,
@@ -27,7 +32,7 @@ export const defaultCharacter: Character = {
people: undefined,
level: 1,
training: MAIN_STATS.reduce((p, v) => { p[v] = { 0: 0 }; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>),
training: MAIN_STATS.reduce((p, v) => { p[v] = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 }; return p; }, {} as Record<MainStat, Partial<Record<TrainingLevel, number>>>),
leveling: { 1: 0 },
abilities: {},
choices: {},
@@ -38,6 +43,7 @@ export const defaultCharacter: Character = {
items: [],
exhaustion: 0,
sickness: [],
poisons: [],
},
owner: -1,
@@ -111,7 +117,10 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
magicelement: 0,
magicinstinct: 0,
},
bonus: {},
bonus: {
abilities: {},
defense: {},
},
resistance: {},
initiative: 0,
capacity: 0,
@@ -123,7 +132,7 @@ const defaultCompiledCharacter: (character: Character) => CompiledCharacter = (c
spells: [],
},
aspect: "",
notes: character.notes ?? "",
notes: Object.assign({ public: '', private: '' }, character.notes),
});
export const mainStatTexts: Record<MainStat, string> = {
@@ -156,6 +165,10 @@ export const elementTexts: Record<SpellElement, { class: string, text: string }>
light: { class: 'text-light-yellow dark:text-dark-yellow border-light-yellow dark:border-dark-yellow bg-light-yellow dark:bg-dark-yellow', text: 'Lumière' },
psyche: { class: 'text-light-purple dark:text-dark-purple border-light-purple dark:border-dark-purple bg-light-purple dark:bg-dark-purple', text: 'Psy' },
};
export const elementDom = (element: SpellElement) => dom("span", {
class: [`border !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px`, elementTexts[element].class],
text: elementTexts[element].text
});
export const alignmentTexts: Record<Alignment, string> = {
'loyal_good': 'Loyal bon',
@@ -202,30 +215,60 @@ 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(),
exhaustion: z.number(),
sickness: z.array(z.object({
id: z.string(),
state: z.number().min(1).max(7).or(z.literal(true)),
})),
poisons: z.array(z.object({
id: z.string(),
state: z.number().min(1).max(7).or(z.literal(true)),
})),
spells: z.array(z.string()),
items: z.array(z.string()),
});
export const CharacterValidation = z.object({
id: z.number(),
name: z.string(),
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()),
choices: z.record(z.string(), z.array(z.number())),
variables: z.object({
health: z.number(),
mana: z.number(),
exhaustion: z.number(),
sickness: z.array(z.object({
id: z.string(),
state: z.number().min(1).max(7).or(z.literal(true)),
})),
spells: z.array(z.string()),
equipment: z.array(z.string()),
}),
variables: CharacterVariablesValidation,
owner: z.number(),
username: z.string().optional(),
visibility: z.enum(["public", "private"]),
@@ -238,7 +281,16 @@ 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)
{
@@ -249,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)
{
@@ -297,6 +357,29 @@ export class CharacterCompiler
{
this._character.variables[prop] = value;
this._result.variables[prop] = value;
this._variableDirty = true;
}
saveVariables()
{
if(this._variableDirty)
{
this._variableDirty = false;
useRequestFetch()(`/api/character/${this.character.id}/variables`, {
method: 'POST',
body: this._character.variables,
}).then(() => {}).catch(() => {
Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true });
})
}
}
saveNotes()
{
return useRequestFetch()(`/api/character/${this.character.id}/notes`, {
method: 'POST',
body: this._character.notes,
}).then(() => {}).catch(() => {
Toaster.add({ type: 'error', content: 'Impossible de mettre à jour les données', duration: 5000, timer: true });
});
}
protected add(feature?: string)
{
@@ -322,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":
@@ -334,12 +417,15 @@ 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];
if(choice)
choice.forEach(e => this.apply(feature.options[e]!));
choice.forEach(e => feature.options[e]!.effects.forEach(this.apply.bind(this)));
return;
default:
@@ -356,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":
@@ -368,69 +454,68 @@ 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];
if(choice)
choice.forEach(e => this.undo(feature.options[e]!));
choice.forEach(e => feature.options[e]!.effects.forEach(this.undo.bind(this)));
return;
default:
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 i = 0; i < buffer.list.length; i++)
for(let j = 0; j < buffer.list.length; j++)
{
if(typeof buffer.list[i]!.value === 'string') // Add or set a modifier
const item = buffer.list[j];
if(!item)
continue;
if(typeof item.value === 'string') // Add or set a modifier
{
const modifier = this._buffer[buffer.list[i]!.value as string];
if(!modifier)
{
queue.push(property);
shortcut = true;
break;
}
else if(modifier._dirty)
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[i]!.value as string);
queue.push(item.value as string);
queue.push(property);
shortcut = true;
break;
}
else
{
if(buffer.list[i]?.operation === 'add')
if(item.operation === 'add')
sum += modifier.value;
else if(buffer.list[i]?.operation === 'set')
else if(item.operation === 'set')
sum = modifier.value;
else if(buffer.list[i]?.operation === 'min')
this._buffer[property]!.min = modifier.value;
else if(item.operation === 'min')
buffer.min = modifier.value;
}
}
else
{
if(buffer.list[i]?.operation === 'add')
sum += buffer.list[i]!.value as number;
else if(buffer.list[i]?.operation === 'set')
sum = buffer.list[i]!.value as number;
else if(buffer.list[i]?.operation === 'min')
this._buffer[property]!.min = buffer.list[i]!.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;
}
}
@@ -491,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,
@@ -508,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,
]));
@@ -540,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`, {
@@ -726,6 +819,7 @@ class PeoplePicker extends BuilderTab
this._options = Object.values(config.peoples).map(
(people, i) => dom("div", { class: "flex flex-col flex-nowrap gap-2 p-2 border border-light-35 dark:border-dark-35 cursor-pointer hover:border-light-70 dark:hover:border-dark-70 w-[320px]", listeners: { click: () => {
this._builder.character.people = people.id;
this._builder.character = { ...this._builder.character, people: people.id };
"border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._options.forEach(f => f?.classList.toggle(e, false)));
"border-accent-blue outline-2 outline outline-accent-blue".split(" ").forEach(e => this._options[i]?.classList.toggle(e, true));
}
@@ -880,7 +974,7 @@ class TrainingPicker extends BuilderTab
return 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 relative"], listeners: { click: e => {
this._builder.toggleTrainingOption(stat, parseInt(level[0]) as TrainingLevel, j);
this.update();
}}}, [ markdownUtil(config.features[option]!.description, undefined, { tags: { a: fakeA } }), choice ]);
}}}, [ markdown(config.features[option]!.description, undefined, { tags: { a: preview } }), choice ]);
}))
]);
}
@@ -1018,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;
}
}
@@ -1136,3 +1228,426 @@ class AspectPicker extends BuilderTab
return true;
}
}
export class CharacterSheet
{
user: ComputedRef<User | null>;
character?: CharacterCompiler;
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>)
{
this.user = user;
const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]);
this.container.replaceChildren(load);
useRequestFetch()(`/api/character/${id}`).then(character => {
if(character)
{
this.character = new CharacterCompiler(character);
document.title = `d[any] - ${character.name}`;
load.remove();
this.render();
}
else
throw new Error();
}).catch((e) => {
console.error(e);
this.container.replaceChildren(div('flex flex-col items-center justify-center flex-1 h-full gap-4', [
span('text-2xl font-bold tracking-wider', 'Personnage introuvable'),
span(undefined, 'Ce personnage n\'existe pas ou est privé.'),
div('flex flex-row gap-4 justify-center items-center', [
button(text('Personnages publics'), () => useRouter().push({ name: 'character-list' }), 'px-2 py-1'),
button(text('Créer un personange'), () => useRouter().push({ name: 'character-id-edit', params: { id: 'new' } }), 'px-2 py-1')
])
]))
});
}
render()
{
if(!this.character)
return;
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) },
{ id: 'abilities', title: [ text('Aptitudes') ], content: () => this.abilitiesTab(character) },
{ id: 'spells', title: [ text('Sorts') ], content: () => this.spellTab(character) },
{ id: 'inventory', title: [ text('Inventaire') ], content: () => [
] },
{ id: 'notes', title: [ text('Notes') ], content: () => [
div('flex flex-col gap-2', [
div('flex flex-col gap-2 border-b border-light-35 dark:border-dark-35 pb-4', [ div('flex flex-row w-full items-center justify-between', [ span('text-lg font-bold', 'Notes publics'), tooltip(button(loadableIcon, saveNotes, 'p-1 items-center justify-center'), 'Enregistrer', 'right') ]), div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 h-64', [ publicNotes.dom ]) ]),
div('flex flex-col gap-2', [ span('text-lg font-bold', 'Notes privés'), div('border border-light-35 dark:border-dark-35 bg-light20 dark:bg-dark-20 p-1 h-64', [ privateNotes.dom ]) ]),
])
] },
], { focused: 'abilities', class: { container: 'flex-1 gap-4 px-4 w-[960px] h-full', content: 'overflow-auto' } });
this.container.replaceChildren(div('flex flex-col justify-start gap-1 h-full', [
div("flex flex-row gap-4 justify-between", [
div(),
div("flex lg:flex-row flex-col gap-6 items-center justify-center", [
div("flex gap-6 items-center", [
div('inline-flex select-none items-center justify-center overflow-hidden align-middle h-16', [
div('text-light-100 dark:text-dark-100 leading-1 flex p-4 items-center justify-center bg-light-25 dark:bg-dark-25 font-medium', [
icon("radix-icons:person", { width: 16, height: 16 }),
])
]),
div("flex flex-col", [
dom("span", { class: "text-xl font-bold", text: character.name }),
dom("span", { class: "text-sm", text: `De ${character.username}` })
]),
div("flex flex-col", [
dom("span", { class: "font-bold", text: `Niveau ${character.level}` }),
dom("span", { text: config.peoples[character.race]?.name ?? 'Peuple inconnu' })
])
]),
div("flex flex-row lg:border-l border-light-35 dark:border-dark-35 py-4 ps-4 gap-8", [
dom("span", { class: "flex flex-row items-center gap-2 text-3xl font-light" }, [
text("PV: "),
dom("span", {
class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35",
text: `${character.health - character.variables.health}`
}),
text(`/ ${character.health}`)
]),
dom("span", { class: "flex flex-row items-center gap-2 text-3xl font-light" }, [
text("Mana: "),
dom("span", {
class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35",
text: `${character.mana - character.variables.mana}`
}),
text(`/ ${character.mana}`)
])
])
]),
div("self-center", [
this.user.value && this.user.value.id === character.owner ?
button(icon("radix-icons:pencil-2"), () => useRouter().push({ name: 'character-id-edit', params: { id: this.character?.character.id } }), "p-1")
: div()
])
]),
div("flex flex-row justify-center 2xl:gap-4 gap-2 p-4 border-b border-light-35 dark:border-dark-35", [
div("flex 2xl:gap-4 gap-2 flex-row items-center justify-between", [
div("flex flex-col items-center px-2", [
dom("span", { class: "2xl:text-2xl text-xl font-bold", text: `+${character.modifier.strength}` }),
dom("span", { class: "text-sm 2xl:text-base", text: "Force" })
]),
div("flex flex-col items-center px-2", [
dom("span", { class: "2xl:text-2xl text-xl font-bold", text: `+${character.modifier.dexterity}` }),
dom("span", { class: "text-sm 2xl:text-base", text: "Dextérité" })
]),
div("flex flex-col items-center px-2", [
dom("span", { class: "2xl:text-2xl text-xl font-bold", text: `+${character.modifier.constitution}` }),
dom("span", { class: "text-sm 2xl:text-base", text: "Constitution" })
]),
div("flex flex-col items-center px-2", [
dom("span", { class: "2xl:text-2xl text-xl font-bold", text: `+${character.modifier.intelligence}` }),
dom("span", { class: "text-sm 2xl:text-base", text: "Intelligence" })
]),
div("flex flex-col items-center px-2", [
dom("span", { class: "2xl:text-2xl text-xl font-bold", text: `+${character.modifier.curiosity}` }),
dom("span", { class: "text-sm 2xl:text-base", text: "Curiosité" })
]),
div("flex flex-col items-center px-2", [
dom("span", { class: "2xl:text-2xl text-xl font-bold", text: `+${character.modifier.charisma}` }),
dom("span", { class: "text-sm 2xl:text-base", text: "Charisme" })
]),
div("flex flex-col items-center px-2", [
dom("span", { class: "2xl:text-2xl text-xl font-bold", text: `+${character.modifier.psyche}` }),
dom("span", { class: "text-sm 2xl:text-base", text: "Psyché" })
])
]),
div('border-l border-light-35 dark:border-dark-35'),
div("flex 2xl:gap-4 gap-2 flex-row items-center justify-between", [
div("flex flex-col px-2 items-center", [
dom("span", { class: "2xl:text-2xl text-xl font-bold", text: `+${character.initiative}` }),
dom("span", { class: "text-sm 2xl:text-base", text: "Initiative" })
]),
div("flex flex-col px-2 items-center", [
dom("span", { class: "2xl:text-2xl text-xl font-bold", text: character.speed === false ? "Aucun déplacement" : `${character.speed} cases` }),
dom("span", { class: "text-sm 2xl:text-base", text: "Course" })
])
]),
div('border-l border-light-35 dark:border-dark-35'),
div("flex 2xl:gap-4 gap-2 flex-row items-center justify-between", [
icon("game-icons:checked-shield", { width: 32, height: 32 }),
div("flex flex-col px-2 items-center", [
dom("span", {
class: "2xl:text-2xl text-xl font-bold",
text: `${clamp(character.defense.static + character.defense.passivedodge + character.defense.passiveparry, 0, character.defense.hardcap)}`
}),
dom("span", { class: "text-sm 2xl:text-base", text: "Passive" })
]),
div("flex flex-col px-2 items-center", [
dom("span", {
class: "2xl:text-2xl text-xl font-bold",
text: `${clamp(character.defense.static + character.defense.passivedodge + character.defense.activeparry, 0, character.defense.hardcap)}`
}),
dom("span", { class: "text-sm 2xl:text-base", text: "Blocage" })
]),
div("flex flex-col px-2 items-center", [
dom("span", {
class: "2xl:text-2xl text-xl font-bold",
text: `${clamp(character.defense.static + character.defense.activedodge + character.defense.passiveparry, 0, character.defense.hardcap)}`
}),
dom("span", { class: "text-sm 2xl:text-base", text: "Esquive" })
])
]),
]),
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', label: 'Compétences', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50")
]),
div("grid grid-cols-2 gap-2",
Object.entries(character.abilities).map(([ability, value]) =>
div("flex flex-row px-1 justify-between items-center", [
span("text-sm text-light-70 dark:text-dark-70 max-w-20 truncate", abilityTexts[ability as Ability] || ability),
span("font-bold text-base text-light-100 dark:text-dark-100", `+${value}`),
])
)
),
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-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', 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', 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", [
div('flex flex-row items-center gap-2', [ text('Précision'), span('font-bold', character.spellranks.precision.toString()) ]),
div('flex flex-row items-center gap-2', [ text('Savoir'), span('font-bold', character.spellranks.knowledge.toString()) ]),
div('flex flex-row items-center gap-2', [ text('Instinct'), span('font-bold', character.spellranks.instinct.toString()) ]),
div('flex flex-row items-center gap-2', [ text('Oeuvres'), span('font-bold', character.spellranks.arts.toString()) ]),
])
])
]),
div('border-l border-light-35 dark:border-dark-35'),
this.tabs,
])
]));
}
actionsTab(character: CompiledCharacter)
{
return [
div('flex flex-col gap-8', [
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Actions', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
div('flex flex-row items-center gap-2', [ ...Array(character.action).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]),
]),
div('flex flex-col gap-2', [
div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Attaquer", "Désarmer", "Saisir", "Faire chuter", "Déplacer", "Courir", "Pas de coté", "Charger", "Lancer un sort", "S'interposer", "Se transformer", "Utiliser un objet", "Anticiper une action", "Improviser"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))),
...(character.lists.action?.map(e => div('flex flex-col gap-1', [
div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: config.action[e]?.name }), config.action[e]?.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: config.action[e]?.cost?.toString() }), text(`point${config.action[e]?.cost > 1 ? 's' : ''} d'action`)]) : undefined]),
markdown(getText(config.action[e]?.description), undefined, { tags: { a: preview } }),
])) ?? [])
]),
]),
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Réactions" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Réaction', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
div('flex flex-row items-center gap-2', [ ...Array(character.reaction).fill(undefined).map(e => div('border border-dashed border-light-50 dark:border-dark-50 w-5 h-5')), dom('span', { class: 'tracking-tight', text: '/ round' }) ]),
]),
div('flex flex-col gap-2', [
div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Parer", "Esquiver", "Saisir une opportunité", "Prendre en tenaille", "Intercepter"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))),
...(character.lists.reaction?.map(e => div('flex flex-col gap-1', [
div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: config.reaction[e]?.name }), config.reaction[e]?.cost ? div('flex flex-row', [dom('span', { class: 'font-bold', text: config.reaction[e]?.cost?.toString() }), text(`point${config.reaction[e]?.cost > 1 ? 's' : ''} d'action`)]) : undefined]),
markdown(getText(config.reaction[e]?.description), undefined, { tags: { a: preview } }),
])) ?? [])
]),
]),
div('flex flex-col gap-2', [
div("flex flex-row items-center justify-center gap-4", [
div("flex flex-row items-center justify-center gap-2", [ dom("div", { class: 'text-lg font-semibold', text: "Actions libres" }), proses('a', preview, [ icon('radix-icons:question-mark-circled', { width: 12, height: 12, class: 'text-light-70 dark:text-dark-70' }) ], { href: 'regles/le-combat/actions-en-combat#Action libre', class: 'h-4' }) ]),
div("flex flex-1 border-t border-dashed border-light-50 dark:border-dark-50"),
]),
div('flex flex-col gap-2', [
div('flex flex-row flex-wrap gap-2 text-light-60 dark:text-dark-60', ["Analyser une situation", "Communiquer", "Dégainer", "Attraper un objet"].map(e => dom('span', { text: e, class: 'cursor-pointer text-sm decoration-dotted underline' }))),
...(character.lists.freeaction?.map(e => div('flex flex-col gap-1', [
div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: config.freeaction[e]?.name }) ]),
markdown(getText(config.freeaction[e]?.description), undefined, { tags: { a: preview } }),
])) ?? [])
]),
]),
]),
]
}
abilitiesTab(character: CompiledCharacter)
{
return [
div('flex flex-col gap-2', [
...(character.lists.passive?.map(e => div('flex flex-col gap-1', [
div('flex flex-row justify-between', [dom('span', { class: 'text-lg', text: config.passive[e]?.name }) ]),
markdown(getText(config.passive[e]?.description), undefined, { tags: { a: preview } }),
])) ?? []),
]),
];
}
spellTab(character: CompiledCharacter)
{
let sortPreference = (localStorage.getItem('character-sort') ?? 'rank') as 'rank' | 'type' | 'element';
const sort = (spells: Array<{ id: string, spell?: SpellConfig, source: string }>) => {
spells = spells.filter(e => !!e.spell);
switch(sortPreference)
{
case 'rank': return spells.sort((a, b) => a.spell!.rank - b.spell!.rank || SPELL_ELEMENTS.indexOf(a.spell!.elements[0]!) - SPELL_ELEMENTS.indexOf(b.spell!.elements[0]!));
case 'type': return spells.sort((a, b) => a.spell!.type.localeCompare(b.spell!.type) || a.spell!.rank - b.spell!.rank);
case 'element': return spells.sort((a, b) => SPELL_ELEMENTS.indexOf(a.spell!.elements[0]!) - SPELL_ELEMENTS.indexOf(b.spell!.elements[0]!) || a.spell!.rank - b.spell!.rank);
default: return spells;
}
};
const spells = sort([...(character.lists.spells ?? []).map(e => ({ id: e, spell: config.spells.find(_e => _e.id === e), source: 'feature' })), ...character.variables.spells.map(e => ({ id: e, spell: config.spells.find(_e => _e.id === e), source: 'player' }))]).map(e => ({...e, dom:
e.spell ? div('flex flex-col gap-2', [
div('flex flex-row items-center gap-4', [ dom('span', { class: 'font-semibold text-lg', text: e.spell.name ?? 'Inconnu' }), div('flex-1 border-b border-dashed border-light-50 dark:border-dark-50'), dom('span', { class: 'text-light-70 dark:text-dark-70', text: `${e.spell.cost ?? 0} mana` }) ]),
div('flex flex-row justify-between items-center gap-2 text-light-70 dark:text-dark-70', [
div('flex flex-row gap-2', [ span('flex flex-row', e.spell.rank === 4 ? 'Sort unique' : `Sort ${e.spell.type === 'instinct' ? 'd\'instinct' : e.spell.type === 'knowledge' ? 'de savoir' : 'de précision'} de rang ${e.spell.rank}`), ...(e.spell.elements ?? []).map(elementDom) ]),
div('flex flex-row gap-2', [ e.spell.concentration ? proses('a', preview, [span('italic text-sm', 'concentration')], { href: '' }) : undefined, span(undefined, typeof e.spell.speed === 'number' ? `${e.spell.speed} minute${e.spell.speed > 1 ? 's' : ''}` : e.spell.speed) ])
]),
div('flex flex-row ps-4 p-1 border-l-4 border-light-35 dark:border-dark-35', [ markdown(e.spell.description) ]),
]) : undefined }));
return [
div('flex flex-col gap-2', [
div('flex flex-row justify-between items-center', [
div('flex flex-row gap-2 items-center', [
dom('span', { class: 'italic tracking-tight text-sm', text: 'Trier par' }),
buttongroup<'rank' | 'type' | 'element'>([{ text: 'Rang', value: 'rank' }, { text: 'Type', value: 'type' }, { text: 'Element', value: 'element' }], { value: sortPreference, class: { option: 'px-2 py-1 text-sm' }, onChange: (value) => { localStorage.setItem('character-sort', value); sortPreference = value; this.tabs?.refresh(); } }),
]),
div('flex flex-row gap-2 items-center', [
dom('span', { class: ['italic text-sm', { 'text-light-red dark:text-dark-red': character.variables.spells.length !== character.spellslots }], text: `${character.variables.spells.length}/${character.spellslots} sort${character.variables.spells.length > 1 ? 's' : ''} maitrisé${character.variables.spells.length > 1 ? 's' : ''}` }),
button(text('Modifier'), () => this.spellPanel(character, spells), 'py-1 px-4'),
])
]),
div('flex flex-col gap-2', spells.map(e => e.dom))
])
]
}
spellPanel(character: CompiledCharacter, spelllist: Array<{ id: string, spell?: SpellConfig, source: string }>)
{
const availableSpells = Object.values(config.spells).filter(spell => {
if (spell.rank === 4) return false;
if (character.spellranks[spell.type] < spell.rank) return false;
return true;
});
const textAmount = text(character.variables.spells.length.toString()), textMax = text(character.spellslots.toString());
const container = div("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-4 text-light-100 dark:text-dark-100 p-8 transition-[width] transition-delay-[150ms]", [
div("flex flex-row justify-between items-center mb-4", [
dom("h2", { class: "text-xl font-bold", text: "Ajouter un sort" }),
div('flex flex-row gap-4 items-center', [ dom('span', { class: 'italic text-light-70 dark:text-dark-70 text-sm' }, [ textAmount, text(' / '), textMax, text(' sorts maitrisés') ]), tooltip(button(icon("radix-icons:cross-1", { width: 20, height: 20 }), () => {
setTimeout(blocker.close, 150);
container.setAttribute('data-state', 'inactive');
}, "p-1"), "Fermer", "left") ])
]),
div('flex flex-col divide-y *:py-2 -my-2 overflow-y-auto', availableSpells.map(spell => {
let state = character.lists.spells?.includes(spell.id) ? 'given' : character.variables.spells.includes(spell.id) ? 'choosen' : 'empty';
const toggleText = text(state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter'), toggleButton = button(toggleText, () => {
if(state === 'choosen')
{
this.character!.variable('spells', character.variables.spells.filter(e => e !== spell.id));
state = 'empty';
}
else if(state === 'empty')
{
this.character!.variable('spells', [...character.variables.spells, spell.id]); //TO REWORK
state = 'choosen';
}
toggleText.textContent = state === 'choosen' ? 'Supprimer' : state === 'given' ? 'Inné' : 'Ajouter';
textAmount.textContent = character.variables.spells.length.toString();
this.tabs?.refresh();
}, "px-2 py-1 text-sm font-normal");
toggleButton.disabled = state === 'given';
return foldable(() => [
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", [
div("flex flex-row text-sm gap-2",
spell.elements.map(el =>
dom("span", {
class: [`border !border-opacity-50 rounded-full !bg-opacity-20 px-2 py-px`, elementTexts[el].class],
text: elementTexts[el].text
})
)
),
div("flex flex-row text-sm gap-1", [
...(spell.rank !== 4 ? [
dom("span", { text: `Rang ${spell.rank}` }),
text("/"),
dom("span", { text: spellTypeTexts[spell.type] }),
text("/")
] : []),
dom("span", { text: `${spell.cost} mana` }),
text("/"),
dom("span", { text: typeof spell.speed === "string" ? spell.speed : `${spell.speed} minutes` })
]),
toggleButton,
]),
]) ], { open: false, class: { container: "px-2 flex flex-col border-light-35 dark:border-dark-35", content: 'py-2' } });
}))
]);
const blocker = fullblocker([ container ], { closeWhenOutside: true, onClose: () => this.character?.saveVariables() });
setTimeout(() => container.setAttribute('data-state', 'active'), 1);
}
}

View File

@@ -1,10 +1,11 @@
import type { RouteLocationAsRelativeTyped, RouteMapGeneric } from "vue-router";
import type { RouteLocationAsRelativeTyped, RouteLocationRaw, RouteMapGeneric } from "vue-router";
import { type NodeProperties, type Class, type NodeChildren, dom, mergeClasses, text, div, icon, type Node } from "./dom.util";
import { 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,24 +21,32 @@ export function loading(size: 'small' | 'normal' | 'large' = 'normal'): HTMLElem
{
return dom('span', { class: ["after:block after:relative after:rounded-full after:border-transparent after:border-t-accent-purple after:animate-spin", {'w-6 h-6 border-4 border-transparent after:-top-[4px] after:-left-[4px] after:w-6 after:h-6 after:border-4': size === 'normal', 'w-4 h-4 after:-top-[2px] after:-left-[2px] after:w-4 after:h-4 after:border-2': size === 'small', 'w-12 h-12 after:-top-[6px] after:-left-[6px] after:w-12 after:h-12 after:border-[6px]': size === 'large'}] })
}
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)
{
const btn = dom('button', { class: [`text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none
/*
text-light-100 dark:text-dark-100 font-semibold hover:bg-light-30 dark:hover:bg-dark-30 inline-flex items-center justify-center bg-light-25 dark:bg-dark-25 leading-none outline-none
border border-light-25 dark:border-dark-25 hover:border-light-30 dark:hover:border-dark-30 active:border-light-40 dark:active:border-dark-40 focus:shadow-raw transition-[box-shadow] focus:shadow-light-40 dark:focus:shadow-dark-40
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50`, cls], listeners: { click: () => disabled || (onClick && onClick()) } }, [ content ]);
disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-none disabled:text-light-50 dark:disabled:text-dark-50
*/
const btn = dom('button', { class: [`inline-flex justify-center items-center outline-none leading-none transition-[box-shadow]
text-light-100 dark:text-dark-100 bg-light-20 dark:bg-dark-20 border border-light-40 dark:border-dark-40
hover:bg-light-25 dark:hover:bg-dark-25 hover:border-light-50 dark:hover:border-dark-50
focus:bg-light-30 dark:focus:bg-dark-30 focus:border-light-50 dark:focus:border-dark-50 focus:shadow-raw focus:shadow-light-50 dark:focus:shadow-dark-50
disabled:text-light-50 dark:disabled:text-dark-50 disabled:bg-light-10 dark:disabled:bg-dark-10 disabled:border-dashed disabled:border-light-40 dark:disabled:border-dark-40`, cls], listeners: { click: () => disabled || (onClick && onClick.bind(btn)()) } }, [ content ]);
let disabled = false;
Object.defineProperty(btn, 'disabled', {
get: () => disabled,
@@ -48,6 +57,34 @@ export function button(content: Node, onClick?: () => void, cls?: Class)
})
return btn;
}
export function buttongroup<T extends any>(options: Array<{ text: string, value: T }>, settings?: { class?: { container?: Class, option?: Class }, value?: T, onChange?: (value: T) => boolean | void })
{
let currentValue = settings?.value;
const elements = options.map(e => dom('div', { class: [`cursor-pointer text-light-100 dark:text-dark-100 hover:bg-light-30 dark:hover:bg-dark-30 flex items-center justify-center bg-light-20 dark:bg-dark-20 leading-none outline-none
border border-light-40 dark:border-dark-40 hover:border-light-50 dark:hover:border-dark-50 data-[selected]:z-10 data-[selected]:border-light-50 dark:data-[selected]:border-dark-50
data-[selected]:shadow-raw transition-[box-shadow] data-[selected]:shadow-light-50 dark:data-[selected]:shadow-dark-50 focus:shadow-raw focus:shadow-light-40 dark:focus:shadow-dark-40`,
settings?.class?.option], text: e.text, attributes: { 'data-selected': settings?.value === e.value }, listeners: { click: function() {
if(currentValue !== e.value)
{
elements.forEach(e => e.toggleAttribute('data-selected', false));
this.toggleAttribute('data-selected', true);
if(!settings?.onChange || settings?.onChange(e.value) !== false)
{
currentValue = e.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
@@ -363,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":
@@ -400,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
]);
@@ -424,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);
@@ -434,6 +476,217 @@ 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;
const titles = tabs.map((e, i) => dom('div', { class: ['px-2 py-1 border-b border-transparent hover:border-accent-blue data-[focus]:border-accent-blue data-[focus]:border-b-[3px] cursor-pointer', settings?.class?.title], attributes: { 'data-focus': e.id === focus }, listeners: { click: function() {
if(this.hasAttribute('data-focus'))
return;
titles.forEach(e => e.toggleAttribute('data-focus', false));
this.toggleAttribute('data-focus', true);
focus = e.id;
const _content = typeof e.content === 'function' ? e.content() : e.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);
const container = div(['flex flex-col', settings?.class?.container], [
div(['flex flex-row items-center gap-1', settings?.class?.tabbar], titles),
content
]);
Object.defineProperty(container, 'refresh', {
writable: false,
configurable: false,
enumerable: false,
value: () => {
let _content = tabs.find(e => e.id === focus)?.content;
_content = (typeof _content === 'function' ? _content() : _content);
_content && content.replaceChildren(..._content.filter(e => !!e));
}
})
return container as HTMLDivElement & { refresh: () => void };
}
export function floater(container: HTMLElement, content: NodeChildren | (() => NodeChildren), settings?: { href?: RouteLocationRaw, class?: Class, position?: Placement, pinned?: boolean, minimizable?: boolean, cover?: 'width' | 'height' | 'all' | 'none', events?: { show: Array<keyof HTMLElementEventMap>, hide: Array<keyof HTMLElementEventMap>, onshow?: (state: FloatState) => boolean, onhide?: (state: FloatState) => boolean }, title?: string })
{
let viewport = document.getElementById('mainContainer') ?? undefined;
let diffX, diffY;
let minimizeBox: DOMRect, minimized = false;
const events: { show: Array<keyof HTMLElementEventMap>, hide: Array<keyof HTMLElementEventMap>, onshow?: (this: HTMLElement, state: FloatState) => boolean, onhide?: (this: HTMLElement, state: FloatState) => boolean } = Object.assign({
show: ['mouseenter', 'mousemove', 'focus'],
hide: ['mouseleave', 'blur'],
}, settings?.events ?? {});
if(settings?.pinned)
{
events.onshow = (state) => {
if(!settings?.events?.onshow || settings?.events?.onshow(state))
{
floating.show();
pin();
}
return false;
};
}
const dragstart = (e: MouseEvent) => {
e.preventDefault();
if(minimized)
return;
window.addEventListener('mousemove', dragmove);
window.addEventListener('mouseup', dragend);
const box = floating.content.getBoundingClientRect();
diffX = e.clientX - box.x;
diffY = e.clientY - box.y;
};
const resizestart = (e: MouseEvent) => {
e.preventDefault();
window.addEventListener('mousemove', resizemove);
window.addEventListener('mouseup', resizeend);
};
const dragmove = (e: MouseEvent) => {
const box = floating.content.getBoundingClientRect();
const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight };
box.x = clamp(e.clientX - diffX!, viewbox?.left ?? 0, viewbox.right - box.width);
box.y = clamp(e.clientY - diffY!, viewbox?.top ?? 0, viewbox.bottom - box.height);
Object.assign(floating.content.style, {
left: `${box.x}px`,
top: `${box.y}px`,
});
};
const resizemove = (e: MouseEvent) => {
const box = floating.content.getBoundingClientRect();
const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight };
box.width = clamp(e.clientX - box.x, 200, Math.min(750, viewbox.right - box.x));
box.height = clamp(e.clientY - box.y, 150, Math.min(750, viewbox.bottom - box.y));
Object.assign(floating.content.style, {
width: `${box.width}px`,
height: `${box.height}px`,
});
};
const dragend = (e: MouseEvent) => {
e.preventDefault();
window.removeEventListener('mousemove', dragmove);
window.removeEventListener('mouseup', dragend);
};
const resizeend = (e: MouseEvent) => {
e.preventDefault();
window.removeEventListener('mousemove', resizemove);
window.removeEventListener('mouseup', resizeend);
};
const pin = () => {
if(floating.content.hasAttribute('data-pinned'))
return;
const box = floating.content.children.item(0)!.getBoundingClientRect();
const viewbox = viewport?.getBoundingClientRect() ?? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight, left: 0, right: window.innerWidth, top: 0, bottom: window.innerHeight };
Object.assign(floating.content.style, {
left: `${clamp(box.left, viewbox.left, viewbox.right)}px`,
top: `${clamp(box.top, viewbox.top, viewbox.bottom)}px`,
width: `${box.width + 21}px`,
height: `${box.height + 21}px`,
});
floating.content.attributeStyleMap.delete('bottom');
floating.content.attributeStyleMap.delete('right');
floating.stop();
floating.content.addEventListener('mousedown', function() {
if(!floating.content.hasAttribute('data-pinned'))
return;
[...this.parentElement?.children ?? []].forEach(e => (e as any as HTMLElement).attributeStyleMap.set('z-index', -1));
this.attributeStyleMap.set('z-index', 0);
}, { passive: true });
}
const minimize = () => {
minimized = !minimized;
floating.content.toggleAttribute('data-minimized', minimized);
if(minimized)
{
minimizeBox = floating.content.getBoundingClientRect();
Object.assign(floating.content.style, {
left: `0px`,
top: `initial`,
bottom: `0px`,
width: `150px`,
height: `21px`,
});
}
else
{
Object.assign(floating.content.style, {
left: `${minimizeBox.left}px`,
top: `${minimizeBox.top}px`,
width: `${minimizeBox.width}px`,
height: `${minimizeBox.height}px`,
});
}
};
const floating = popper(container, {
arrow: true,
delay: settings?.pinned ? 0 : 150,
offset: 12,
cover: settings?.cover,
placement: settings?.position,
class: 'bg-light-10 dark:bg-dark-10 border border-light-35 dark:border-dark-35 group-data-[pinned]:bg-light-15 dark:group-data-[pinned]:bg-dark-15 group-data-[pinned]:border-light-50 dark:group-data-[pinned]:border-dark-50 text-light-100 dark:text-dark-100 z-[45] relative group-data-[pinned]:h-full',
content: () => [
settings?.pinned !== undefined ? div('hidden group-data-[pinned]:flex flex-row items-center border-b border-light-35 dark:border-dark-35', [
dom('span', { class: 'flex-1 w-full h-full cursor-move group-data-[minimized]:cursor-default text-xs px-2', listeners: { mousedown: dragstart }, text: (settings?.title?.substring(0, 1)?.toUpperCase() ?? '') + (settings?.title?.substring(1)?.toLowerCase() ?? '') }),
settings?.title ? tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { click: minimize } }, [icon('radix-icons:minus', { width: 12, height: 12, class: 'p-1' })]), text('Réduire'), 'top') : undefined,
settings?.href ? tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { mousedown: (e) => { ((e.ctrlKey || e.button === 1) ? window.open : useRouter().push)(useRouter().resolve(settings.href!).href); floating.hide(); } } }, [icon('radix-icons:external-link', { width: 12, height: 12, class: 'p-1' })]), 'Ouvrir', 'top') : undefined,
tooltip(dom('div', { class: 'cursor-pointer flex', listeners: { mousedown: (e) => {
e.stopImmediatePropagation();
floating.hide();
floating.content.toggleAttribute('data-minimized', false);
minimized && Object.assign(floating.content.style, {
left: `${minimizeBox.left}px`,
top: `${minimizeBox.top}px`,
width: `${minimizeBox.width}px`,
height: `${minimizeBox.height}px`,
});
minimized = false;
} } }, [icon('radix-icons:cross-1', { width: 12, height: 12, class: 'p-1' })]), 'Fermer', 'top') ]) : undefined,
div('group-data-[minimized]:hidden h-full group-data-[pinned]:h-[calc(100%-21px)] w-full min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] group-data-[pinned]:min-h-[initial] group-data-[pinned]:min-w-[initial] group-data-[pinned]:max-h-[initial] group-data-[pinned]:max-w-[initial] overflow-auto box-content', typeof content === 'function' ? content() : content), dom('span', { class: 'hidden group-data-[pinned]:flex group-data-[minimized]:hidden absolute bottom-0 right-0 cursor-nw-resize z-50', listeners: { mousedown: resizestart } }, [ icon('ph:notches', { width: 12, height: 12 }) ])
],
viewport,
events: events
});
if(settings?.pinned === false)
floating.content.addEventListener('dblclick', pin);
return container;
}
export interface ToastConfig
{
@@ -448,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,12 +1,12 @@
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> = | ((ev: HTMLElementEventMap[K]) => any) | {
type Listener<K extends keyof HTMLElementEventMap> = | ((this: HTMLElement, ev: HTMLElementEventMap[K]) => any) | {
options?: boolean | AddEventListenerOptions;
listener: (ev: HTMLElementEventMap[K]) => any;
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any;
} | undefined;
export interface NodeProperties
@@ -42,9 +42,9 @@ export function dom<K extends keyof HTMLElementTagNameMap>(tag: K, properties?:
{
const key = k as keyof HTMLElementEventMap, value = v as Listener<typeof key>;
if(typeof value === 'function')
element.addEventListener(key, value);
element.addEventListener(key, value.bind(element));
else if(value)
element.addEventListener(key, value.listener, value.options);
element.addEventListener(key, value.listener.bind(element), value.options);
}
}
@@ -56,7 +56,11 @@ export function div(cls?: Class, children?: NodeChildren): HTMLDivElement
{
return dom("div", { class: cls }, children);
}
export function svg<K extends keyof SVGElementTagNameMap>(tag: K, properties?: NodeProperties, children?: Omit<NodeChildren, 'HTMLElement' | 'Text'>): SVGElementTagNameMap[K]
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?: SVGElement[]): SVGElementTagNameMap[K]
{
const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
@@ -116,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);
properties?.noobserver && el.toggleAttribute('noobserver', properties?.noobserver);
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

File diff suppressed because it is too large Load Diff

View File

@@ -17,38 +17,44 @@ 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
{
priority?: boolean;
closeWhenOutside?: boolean;
onClose?: () => boolean | void;
}
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: [
@@ -58,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',
@@ -73,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)
{
@@ -84,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,
@@ -138,41 +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,
'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)
{
@@ -288,22 +348,24 @@ 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)
{
const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (() => _modal.remove()) : undefined } });
if(!content)
return { close: () => {} };
const close = () => (!properties?.onClose || properties.onClose() !== false) && _modal.remove();
const _modalBlocker = dom('div', { class: [' absolute top-0 left-0 bottom-0 right-0 z-0', { 'bg-light-0 dark:bg-dark-0 opacity-70': properties?.priority ?? false }], listeners: { click: properties?.closeWhenOutside ? (close) : undefined } });
const _modal = dom('div', { class: 'fixed flex justify-center items-center top-0 left-0 bottom-0 right-0 inset-0 z-40' }, [ _modalBlocker, ...content]);
teleport.appendChild(_modal);
return {
close: () => _modal.remove(),
}
return { close };
}
export function modal(content: NodeChildren, properties?: ModalProperties)
{

View File

@@ -4,13 +4,13 @@ import prose, { a, blockquote, tag, h1, h2, h3, h4, h5, hr, li, small, table, td
import { heading } from "hast-util-heading";
import { 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 } from "#shared/dom.util";
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,8 +20,8 @@ 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', attributes: { href: nav.href }, listeners: {
'click': (e) => {
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,86 +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: 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-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 fakeA: Prose = {
custom(properties, children) {
export const preview: Prose = {
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' }, [
dom('span', {}, [
...(children ?? []),
overview && overview.type !== 'markdown' ? icon(iconByType[overview.type], { class: 'w-4 h-4 inline-block', inline: true }) : undefined
])
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
]);
if(!!overview)
{
const magicKeys = useMagicKeys();
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 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: 'min-w-[200px] min-h-[150px] max-w-[600px] max-h-[600px] w-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;
},
});
}
return el;
const magicKeys = useMagicKeys();
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 = {

105
types/character.d.ts vendored
View File

@@ -1,4 +1,5 @@
import type { MAIN_STATS, ABILITIES, LEVELS, TRAINING_LEVELS, SPELL_TYPES, CATEGORIES, SPELL_ELEMENTS, ALIGNMENTS, RESISTANCES } from "#shared/character.util";
import type { MAIN_STATS, ABILITIES, LEVELS, TRAINING_LEVELS, SPELL_TYPES, CATEGORIES, SPELL_ELEMENTS, ALIGNMENTS, RESISTANCES, DAMAGE_TYPES, WEAPON_TYPES } from "#shared/character.util";
import type { Localized } from "../types/general";
export type MainStat = typeof MAIN_STATS[number];
export type Ability = typeof ABILITIES[number];
@@ -9,18 +10,28 @@ 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;
export type RecursiveKeyOf<TObj extends object> = {
[TKey in keyof TObj & (string | number)]:
TObj[TKey] extends any[] ? `${TKey}` :
TObj[TKey] extends object
? `${TKey}` | `${TKey}/${RecursiveKeyOf<TObj[TKey]>}`
: `${TKey}`;
}[keyof TObj & (string | number)];
export type Character = {
id: number;
name: string;
people?: string;
name: string; //Free text
people?: string; //People ID
level: number;
aspect?: number;
notes?: string | null;
notes?: { public?: string, private?: string }; //Free text
training: Record<MainStat, Partial<Record<TrainingLevel, number>>>;
leveling: Partial<Record<Level, number>>;
@@ -38,11 +49,12 @@ export type CharacterVariables = {
exhaustion: number;
sickness: Array<{ id: string, state: number | true }>;
poisons: Array<{ id: string, state: number | true }>;
spells: string[]; //Spell ID
items: ItemState[];
};
type ItemState = {
id: string,
id: string;
amount: number;
enchantments?: [];
charges?: number;
@@ -55,66 +67,78 @@ export type CharacterConfig = {
spells: SpellConfig[];
aspects: AspectConfig[];
features: Record<FeatureID, Feature>;
enchantments: Record<string, { name: string, effect: FeatureEffect[], power: number }>; //TODO
enchantments: Record<string, EnchantementConfig>; //TODO
items: Record<string, ItemConfig>;
lists: Record<string, { id: string, name: string, [key: string]: any }[]>;
sickness: Record<string, { id: string, name: string, description: string, effect: FeatureID[] }>;
action: Record<string, { id: string, name: string, description: string, cost: number }>;
reaction: Record<string, { id: string, name: string, description: string, cost: number }>;
freeaction: Record<string, { id: string, name: string, description: string }>;
passive: Record<string, { id: string, name: string, description: string }>;
texts: Record<i18nID, Localized>;
};
export type EnchantementConfig = {
name: string; //TODO -> TextID
effect: Array<FeatureEquipment | FeatureValue | FeatureList>;
power: number;
}
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: FeatureEffect[];
};
type MundaneConfig = {
category: 'mundane';
name: string; //TODO -> TextID
description: i18nID;
};
export type SpellConfig = {
id: string;
name: string;
name: string; //TODO -> TextID
rank: 1 | 2 | 3 | 4;
type: SpellType;
cost: number;
speed: "action" | "reaction" | number;
elements: Array<SpellElement>;
effect: string;
description: string; //TODO -> TextID
concentration: boolean;
range: 'personnal' | number;
tags?: string[];
};
export type RaceConfig = {
id: string;
name: string;
description: string;
name: string; //TODO -> TextID
description: string; //TODO -> TextID
options: Record<Level, FeatureID[]>;
};
export type AspectConfig = {
name: string;
description: string;
description: string; //TODO -> TextID
stat: MainStat | 'special';
alignment: Alignment;
magic: boolean;
@@ -122,36 +146,44 @@ export type AspectConfig = {
physic: { min: number, max: number };
mental: { min: number, max: number };
personality: { min: number, max: number };
options: FeatureEffect[];
options: FeatureItem[];
};
export type FeatureEffect = {
export type FeatureValue = {
id: FeatureID;
category: "value";
operation: "add" | "set" | "min";
property: string;
property: RecursiveKeyOf<CompiledCharacter> | 'spec' | 'ability' | 'training';
value: number | `modifier/${MainStat}` | false;
} | {
}
export type FeatureEquipment = {
id: FeatureID;
category: "value";
operation: "add" | "set" | "min";
property: 'weapon/damage/value' | 'armor/health' | 'armor/absorb/flat' | 'armor/absorb/percent';
value: number | `modifier/${MainStat}` | false;
}
export type FeatureList = {
id: FeatureID;
category: "list";
list: "spells" | "sickness" | "action" | "reaction" | "freeaction" | "passive";
action: "add" | "remove";
item: string;
extra?: any;
};
export type FeatureItem = FeatureEffect | {
export type FeatureChoice = {
id: FeatureID;
category: "choice";
text: string;
text: string; //TODO -> TextID
settings?: { //If undefined, amount is 1 by default
amount: number;
exclusive: boolean; //Disallow to pick the same option twice
};
options: Array<FeatureEffect & { text: string }>;
options: Array<{ text: string, effects: Array<FeatureValue | FeatureList> }>; //TODO -> TextID
};
export type FeatureItem = FeatureValue | FeatureList | FeatureChoice;
export type Feature = {
id: FeatureID;
description: i18nID;
description: string; //TODO -> TextID
effect: FeatureItem[];
};
@@ -199,13 +231,16 @@ export type CompiledCharacter = {
magicinstinct: number;
};
bonus: Record<string, number>; //Any special bonus goes here
bonus: {
defense: Partial<Record<MainStat, number>>;
abilities: Partial<Record<Ability, number>>;
}; //Any special bonus goes here
resistance: Record<string, number>;
modifier: Record<MainStat, number>;
abilities: Partial<Record<Ability, number>>;
level: number;
lists: { [K in Extract<FeatureEffect, { category: "list" }>["list"]]?: string[] };
lists: { [K in FeatureList['list']]?: string[] }; //string => ListItem ID
notes: string;
notes: { public: string, private: string };
};