Add character selection using campaign visibility and player characters in campaign
This commit is contained in:
parent
d8480e7366
commit
3de2b0fe19
|
|
@ -1,6 +1,7 @@
|
||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
|
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import * as schema from '../db/schema';
|
import * as schema from '../db/schema';
|
||||||
|
import { eq, or, sql } from "drizzle-orm";
|
||||||
|
|
||||||
let instance: BunSQLiteDatabase<typeof schema> & {
|
let instance: BunSQLiteDatabase<typeof schema> & {
|
||||||
$client: Database;
|
$client: Database;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { User } from "./auth";
|
||||||
|
import type { Character, ItemState } from "./character";
|
||||||
|
import type { Serialize } from 'nitropack';
|
||||||
|
|
||||||
|
export type CampaignVariables = {
|
||||||
|
money: number;
|
||||||
|
inventory: ItemState[];
|
||||||
|
};
|
||||||
|
export type Campaign = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
link: string;
|
||||||
|
owner: { id: number, username: string };
|
||||||
|
members: Array<{ member: { id: number, username: string } }>;
|
||||||
|
characters: Array<Partial<{ character: { id: number, name: string, owner: number } }>>;
|
||||||
|
public_notes: string;
|
||||||
|
dm_notes: string;
|
||||||
|
logs: CampaignLog[];
|
||||||
|
} & CampaignVariables;
|
||||||
|
export type CampaignLog = {
|
||||||
|
from: number;
|
||||||
|
timestamp: Serialize<Date>;
|
||||||
|
type: 'ITEM' | 'CHARACTER' | 'PLACE' | 'EVENT' | 'FIGHT' | 'TEXT';
|
||||||
|
details: string;
|
||||||
|
};
|
||||||
|
|
@ -19,20 +19,17 @@ export default defineEventHandler(async (e) => {
|
||||||
|
|
||||||
const db = useDatabase();
|
const db = useDatabase();
|
||||||
const data = db.query.campaignTable.findFirst({
|
const data = db.query.campaignTable.findFirst({
|
||||||
|
columns: { owner: false },
|
||||||
with: {
|
with: {
|
||||||
members: { with: { member: { columns: { username: true, id: true } } }, columns: { id: false, rights: false, user: false }, extras: { 'characters': sql`NULL`.as('characters') } },
|
members: { with: { member: { columns: { username: true, id: true } } }, columns: { id: false, user: false } },
|
||||||
characters: { with: { character: { columns: { id: true, name: true, owner: true } } } },
|
characters: { with: { character: { columns: { id: true, name: true, owner: true } } }, columns: { character: false } },
|
||||||
owner: { columns: { username: true, id: true } },
|
owner: { columns: { username: true, id: true } },
|
||||||
logs: { columns: { details: true, from: true, timestamp: true, type: true }, orderBy: ({ timestamp }) => timestamp }
|
logs: { columns: { details: true, from: true, timestamp: true, type: true }, orderBy: ({ timestamp }) => timestamp },
|
||||||
},
|
},
|
||||||
where: ({ id: _id }) => eq(_id, parseInt(id, 10)),
|
where: ({ id: _id }) => eq(_id, parseInt(id, 10)),
|
||||||
}).sync();
|
}).sync();
|
||||||
|
|
||||||
if(data && (data.owner.id === session.user.id || data.members.find(e => e.member?.id === session.user!.id)))
|
if(data && (data.owner.id === session.user.id || data.members.find(e => e.member?.id === session.user!.id))) return data as Campaign;
|
||||||
{
|
|
||||||
data.members.forEach(e => e.characters = data.characters.filter(_e => _e.character?.owner === e.member?.id));
|
|
||||||
return data as Campaign;
|
|
||||||
}
|
|
||||||
else if(!data) return setResponseStatus(e, 404);
|
else if(!data) return setResponseStatus(e, 404);
|
||||||
else return setResponseStatus(e, 403);
|
else return setResponseStatus(e, 403);
|
||||||
});
|
});
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import useDatabase from '~/composables/useDatabase';
|
import useDatabase from '~/composables/useDatabase';
|
||||||
import { characterTable } from '~/db/schema';
|
import { campaignCharactersTable, campaignMembersTable, campaignTable, characterAbilitiesTable, characterChoicesTable, characterLevelingTable, characterTable, characterTrainingTable, usersTable } from '~/db/schema';
|
||||||
import { group } from '#shared/general.util';
|
import { group } from '#shared/general.util';
|
||||||
import type { Character, CharacterVariables, Level, MainStat, TrainingLevel } from '~/types/character';
|
import type { Character, MainStat, TrainingLevel } from '~/types/character';
|
||||||
|
import { and, eq, exists, getTableColumns, isNotNull, or, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
export default defineEventHandler(async (e) => {
|
export default defineEventHandler(async (e) => {
|
||||||
const id = getRouterParam(e, "id");
|
const id = getRouterParam(e, "id");
|
||||||
|
|
@ -21,6 +22,7 @@ export default defineEventHandler(async (e) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = useDatabase();
|
const db = useDatabase();
|
||||||
|
|
||||||
const character = db.query.characterTable.findFirst({
|
const character = db.query.characterTable.findFirst({
|
||||||
with: {
|
with: {
|
||||||
abilities: true,
|
abilities: true,
|
||||||
|
|
@ -29,9 +31,23 @@ export default defineEventHandler(async (e) => {
|
||||||
choices: true,
|
choices: true,
|
||||||
user: {
|
user: {
|
||||||
columns: { username: true }
|
columns: { username: true }
|
||||||
|
},
|
||||||
|
campaign: {
|
||||||
|
columns: { character: false, id: false, },
|
||||||
|
with: {
|
||||||
|
campaign: {
|
||||||
|
columns: { owner: true, },
|
||||||
|
with: {
|
||||||
|
members: { columns: { user: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
where: (character, { eq, and }) => and(eq(character.id, parseInt(id, 10)), eq(characterTable.owner, session.user!.id)),
|
where: and(eq(characterTable.id, parseInt(id, 10)), or(eq(characterTable.visibility, 'public'), eq(characterTable.owner, session.user!.id), exists(db.select({ id: sql`NULL` }).from(campaignCharactersTable)
|
||||||
|
.leftJoin(campaignTable, eq(campaignCharactersTable.id, campaignTable.id))
|
||||||
|
.leftJoin(campaignMembersTable, and(eq(campaignMembersTable.id, campaignTable.id), eq(campaignMembersTable.user, session.user.id)))
|
||||||
|
.where(and(eq(campaignCharactersTable.character, parseInt(id, 10)), or(eq(campaignTable.owner, session.user.id), isNotNull(campaignMembersTable.user))))))),
|
||||||
}).sync();
|
}).sync();
|
||||||
|
|
||||||
if(character !== undefined)
|
if(character !== undefined)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { div, dom, icon, span, text } from "#shared/dom.util";
|
||||||
import { button, loading } from "#shared/components.util";
|
import { button, loading } from "#shared/components.util";
|
||||||
import { CharacterCompiler } from "#shared/character.util";
|
import { CharacterCompiler } from "#shared/character.util";
|
||||||
import { tooltip } from "./floating.util";
|
import { tooltip } from "./floating.util";
|
||||||
|
import type { Character } from "~/types/character";
|
||||||
|
|
||||||
export const CampaignValidation = z.object({
|
export const CampaignValidation = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
|
|
@ -27,13 +28,33 @@ async function copyLink()
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
class CharacterPrinter
|
||||||
|
{
|
||||||
|
private compiler?: CharacterCompiler;
|
||||||
|
container: HTMLElement = div('flex flex-col gap-2');
|
||||||
|
constructor(character: number, name: string)
|
||||||
|
{
|
||||||
|
this.container.replaceChildren(div('flex flex-row gap-1', [span(undefined, 'joue'), span('font-bold', name)]), loading('small'));
|
||||||
|
useRequestFetch()(`/api/character/${character}`).then((character) => {
|
||||||
|
if(character)
|
||||||
|
{
|
||||||
|
this.compiler = new CharacterCompiler(character);
|
||||||
|
this.container.replaceChildren(div('flex flex-row gap-1', [span(undefined, 'joue'), span('font-bold', name)]), );
|
||||||
|
}
|
||||||
|
else throw new Error();
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
this.container.replaceChildren(span('text-sm italic text-light-red dark:text-dark-red', 'Données indisponible'));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
type PlayerState = {
|
type PlayerState = {
|
||||||
status: boolean;
|
status: boolean;
|
||||||
statusDOM: HTMLElement;
|
statusDOM: HTMLElement;
|
||||||
statusTooltip: Text;
|
statusTooltip: Text;
|
||||||
user: User;
|
user: { id: number, username: string };
|
||||||
};
|
};
|
||||||
function defaultPlayerState(user: User): PlayerState
|
function defaultPlayerState(user: { id: number, username: string }): PlayerState
|
||||||
{
|
{
|
||||||
const statusTooltip = text('Absent');
|
const statusTooltip = text('Absent');
|
||||||
return {
|
return {
|
||||||
|
|
@ -49,16 +70,21 @@ export class CampaignSheet
|
||||||
campaign?: Campaign;
|
campaign?: Campaign;
|
||||||
container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center');
|
container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center');
|
||||||
dm!: PlayerState;
|
dm!: PlayerState;
|
||||||
|
players!: Array<PlayerState & { characters: CharacterPrinter[] }>
|
||||||
constructor(id: string, user: ComputedRef<User | null>)
|
constructor(id: string, user: ComputedRef<User | null>)
|
||||||
{
|
{
|
||||||
this.user = user;
|
this.user = user;
|
||||||
const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]);
|
const load = div("flex justify-center items-center w-full h-full", [ loading('large') ]);
|
||||||
this.container.replaceChildren(load);
|
this.container.replaceChildren(load);
|
||||||
useRequestFetch()(`/api/campaign/${id}`).then(campaign => {
|
useRequestFetch()(`/api/campaign/${id}`).then((campaign) => {
|
||||||
if(campaign)
|
if(campaign)
|
||||||
{
|
{
|
||||||
this.campaign = campaign;
|
this.campaign = campaign;
|
||||||
this.dm = defaultPlayerState(campaign.owner);
|
this.dm = defaultPlayerState(campaign.owner);
|
||||||
|
this.players = campaign.members.map(e => ({
|
||||||
|
...defaultPlayerState(e.member),
|
||||||
|
characters: campaign.characters.filter(_e => _e.character?.owner === e.member.id).map(_e => new CharacterPrinter(_e.character!.id, _e.character!.name)),
|
||||||
|
}));
|
||||||
document.title = `d[any] - Campagne ${campaign.name}`;
|
document.title = `d[any] - Campagne ${campaign.name}`;
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
|
|
@ -77,14 +103,19 @@ export class CampaignSheet
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
render()
|
private render()
|
||||||
{
|
{
|
||||||
|
const campaign = this.campaign;
|
||||||
|
|
||||||
|
if(!campaign)
|
||||||
|
return;
|
||||||
|
|
||||||
this.container.replaceChildren(div('flex flex-col w-full h-full items-center gap-4', [
|
this.container.replaceChildren(div('flex flex-col w-full h-full items-center gap-4', [
|
||||||
div('flex flex-row gap-8 items-center', [
|
div('flex flex-row gap-8 items-center', [
|
||||||
div('text-2xl font-semibold', [text(this.campaign.name)]),
|
div('text-2xl font-semibold', [text(campaign.name)]),
|
||||||
div('border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2', [
|
div('border border-light-35 dark:border-dark-35 p-1 flex flex-row items-center gap-2', [
|
||||||
dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`https://d-any.com/campaign/join/${ encodeURIComponent(this.campaign.link) }`) ]),
|
dom('pre', { class: 'ps-1 w-[400px] truncate' }, [ text(`https://d-any.com/campaign/join/${ encodeURIComponent(campaign.link) }`) ]),
|
||||||
button(icon('radix-icons:clipboard', { width: 16, height: 16 })),
|
button(icon('radix-icons:clipboard', { width: 16, height: 16 }), () => {}, 'p-1'),
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
div('flex flex-row gap-4 flex-1', [
|
div('flex flex-row gap-4 flex-1', [
|
||||||
|
|
@ -93,73 +124,29 @@ export class CampaignSheet
|
||||||
div('flex flex-col divide-y divide-light-25 dark:divide-dark-25', [
|
div('flex flex-col divide-y divide-light-25 dark:divide-dark-25', [
|
||||||
div('flex flex-col py-1 my-1', [
|
div('flex flex-col py-1 my-1', [
|
||||||
div('flex flex-row items-center justify-between', [
|
div('flex flex-row items-center justify-between', [
|
||||||
span(undefined, this.campaign.owner.username),
|
span(undefined, this.dm.user.username),
|
||||||
this.dm.statusDOM,
|
this.dm.statusDOM,
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
div('flex flex-row items-center gap-4', [ span('text-lg font-bold tracking-tight', 'Joueurs'), span('border-b border-dashed border-light-35 dark:border-dark-35 flex-1') ]),
|
div('flex flex-row items-center gap-4', [ span('text-lg font-bold tracking-tight', 'Joueurs'), span('border-b border-dashed border-light-35 dark:border-dark-35 flex-1') ]),
|
||||||
div('flex flex-col divide-y divide-light-25 dark:divide-dark-25',
|
div('flex flex-col divide-y divide-light-25 dark:divide-dark-25',
|
||||||
this.campaign.members.length > 0 ? this.campaign.members.map((player: any) => div('flex flex-col py-1 my-1', [
|
this.players.length > 0 ? this.players.map((player) => div('flex flex-col py-1 my-1', [
|
||||||
div('flex flex-row items-center justify-between', [
|
div('flex flex-row items-center justify-between', [
|
||||||
span(undefined, player.member.username),
|
span(undefined, player.user.username),
|
||||||
undefined,
|
player.statusDOM,
|
||||||
]),
|
]),
|
||||||
player.characters.length > 0 ? div('flex flex-col',
|
player.characters && player.characters.length > 0 ? div('flex flex-col',
|
||||||
player.characters.map((character: any) => div('flex flex-row items-center justify-between', [
|
player.characters.map((character) => div('flex flex-row items-center justify-between', [
|
||||||
span(undefined, 'joue'), span('text-bold', character.name)
|
character.container
|
||||||
]))
|
]))
|
||||||
) : span('text-sm italic text-light-70 dark:text-dark-70', 'Pas de personnages'),
|
) : span('text-sm italic text-light-70 dark:text-dark-70', 'Pas de personnages'),
|
||||||
])) : span('text-sm italic py-2 text-center', 'Invitez des joueurs via le lien'),
|
])) : [span('text-sm italic py-2 text-center', 'Invitez des joueurs via le lien')],
|
||||||
)
|
)
|
||||||
]),
|
]),
|
||||||
div('border-l border-light-35 dark:border-dark-35'),
|
div('border-l border-light-35 dark:border-dark-35'),
|
||||||
div('flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-[800px]')
|
div('flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-[800px]')
|
||||||
])
|
])
|
||||||
]));
|
]));
|
||||||
/*
|
|
||||||
<div class="flex flex-col w-full h-full items-center gap-4" v-else-if="campaign && status === 'success'">
|
|
||||||
<div class="flex flex-row gap-4 flex-1">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div class="flex flex-row items-center gap-4"><span class="text-lg font-bold tracking-tight flex-1">Maitre de jeu</span><span class="border-b border-dashed border-light-35 dark:border-dark-35 flex-1"></span></div>
|
|
||||||
<div class="flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-64">
|
|
||||||
<div class="flex flex-col py-1 my-1">
|
|
||||||
<div class="flex flex-row items-center justify-between">
|
|
||||||
<span>{{ campaign.owner.username }}</span>
|
|
||||||
<Tooltip message="Absent" side="right" :delay="0"><span class="rounded-full w-3 h-3 block border-light-50 dark:border-dark-50 border-2 border-dashed"></span></Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-4"><span class="text-lg font-bold tracking-tight">Joueurs</span><span class="border-b border-dashed border-light-35 dark:border-dark-35 w-full"></span></div>
|
|
||||||
<div class="flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-64">
|
|
||||||
<template v-if="campaign.members.length > 0">
|
|
||||||
<div v-for="player of campaign.members" class="flex flex-col py-1 my-1">
|
|
||||||
<div class="flex flex-row items-center justify-between">
|
|
||||||
<span>{{ player.member?.username }}</span>
|
|
||||||
<Tooltip message="Absent" side="right" :delay="0"><span class="rounded-full w-3 h-3 block border-light-50 dark:border-dark-50 border-2 border-dashed"></span></Tooltip>
|
|
||||||
</div>
|
|
||||||
<template v-if="player.characters.length > 0">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div class="flex flex-row items-center justify-between" v-for="character of player.characters">
|
|
||||||
<span>joue {{ character.character.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="text-sm italic text-light-70 dark:text-dark-70">Sans personnage</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="text-sm italic py-2 text-center">Invitez des joueurs via le lien d'invitation</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="border-l border-light-35 dark:border-dark-35"></div>
|
|
||||||
<div class="flex flex-col divide-y divide-light-25 dark:divide-dark-25 w-[800px]">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1359,7 +1359,7 @@ export class CharacterSheet
|
||||||
readonly: dom("span", {
|
readonly: dom("span", {
|
||||||
class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35",
|
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 - character.variables.health}`,
|
||||||
listeners: { click: () => health.readonly.replaceWith(health.edit) },
|
listeners: { click: () => { health.readonly.replaceWith(health.edit); health.edit.select(); health.edit.focus(); } },
|
||||||
}),
|
}),
|
||||||
edit: input('text', { defaultValue: (character.health - character.variables.health).toString(), input: (v) => {
|
edit: input('text', { defaultValue: (character.health - character.variables.health).toString(), input: (v) => {
|
||||||
return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10));
|
return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10));
|
||||||
|
|
@ -1370,7 +1370,7 @@ export class CharacterSheet
|
||||||
readonly: dom("span", {
|
readonly: dom("span", {
|
||||||
class: "font-bold px-2 border-transparent border cursor-pointer hover:border-light-35 dark:hover:border-dark-35",
|
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 - character.variables.mana}`,
|
||||||
listeners: { click: () => mana.readonly.replaceWith(mana.edit) },
|
listeners: { click: () => { mana.readonly.replaceWith(mana.edit); mana.edit.select(); mana.edit.focus(); } },
|
||||||
}),
|
}),
|
||||||
edit: input('text', { defaultValue: (character.mana - character.variables.mana).toString(), input: (v) => {
|
edit: input('text', { defaultValue: (character.mana - character.variables.mana).toString(), input: (v) => {
|
||||||
return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10));
|
return v.startsWith('-') || v.startsWith('+') ? v.length === 1 || !isNaN(parseInt(v.substring(1), 10)) : v.length === 0 || !isNaN(parseInt(v, 10));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue