WebSocket API, new ID/encrypt/decrypt algorithm.
This commit is contained in:
parent
2a158be3fa
commit
7a40f8abac
|
|
@ -1,80 +0,0 @@
|
|||
<template>
|
||||
<TreeRoot v-bind="forward" v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 overflow-auto max-h-full">
|
||||
<DraggableTreeItem v-for="item in flattenItems" :key="item._id" v-bind="item" class="group flex items-center outline-none relative cursor-pointer max-w-full" @select.prevent @toggle.prevent>
|
||||
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, isDraggedOver }">
|
||||
<slot :handleToggle="handleToggle"
|
||||
:handleSelect="handleSelect"
|
||||
:isExpanded="isExpanded"
|
||||
:isSelected="isSelected"
|
||||
:isDragging="isDragging"
|
||||
:isDraggedOver="isDraggedOver"
|
||||
:item="item"
|
||||
/>
|
||||
</template>
|
||||
<template #hint="{ instruction }">
|
||||
<div v-if="instruction">
|
||||
<slot name="hint" :instruction="instruction" />
|
||||
</div>
|
||||
</template>
|
||||
</DraggableTreeItem>
|
||||
</TreeRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { useForwardPropsEmits, type FlattenedItem, type TreeRootEmits, type TreeRootProps } from 'radix-vue';
|
||||
import { type Instruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
|
||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
|
||||
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
|
||||
const props = defineProps<TreeRootProps<T>>();
|
||||
const emits = defineEmits<TreeRootEmits<T> & {
|
||||
'updateTree': [instruction: Instruction, itemId: string, targetId: string];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
default: (props: {
|
||||
handleToggle: () => void,
|
||||
handleSelect: () => void,
|
||||
isExpanded: boolean,
|
||||
isSelected: boolean,
|
||||
isDragging: boolean,
|
||||
isDraggedOver: boolean,
|
||||
item: FlattenedItem<T>,
|
||||
}) => any,
|
||||
hint: (props: {
|
||||
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
|
||||
}) => any,
|
||||
}>();
|
||||
|
||||
const forward = useForwardPropsEmits(props, emits);
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const dndFunction = combine(
|
||||
monitorForElements({
|
||||
onDrop(args) {
|
||||
const { location, source } = args;
|
||||
|
||||
if (!location.current.dropTargets.length)
|
||||
return;
|
||||
|
||||
const itemId = source.data.id as string;
|
||||
const target = location.current.dropTargets[0];
|
||||
const targetId = target.data.id as string;
|
||||
|
||||
const instruction: Instruction | null = extractInstruction(
|
||||
target.data,
|
||||
);
|
||||
|
||||
if (instruction !== null)
|
||||
{
|
||||
emits('updateTree', instruction, itemId, targetId);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
dndFunction();
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
<template>
|
||||
<TreeItem ref="el" v-bind="forward" v-slot="{ isExpanded, isSelected, isIndeterminate, handleToggle, handleSelect }">
|
||||
<slot
|
||||
:is-expanded="isExpanded"
|
||||
:is-selected="isSelected"
|
||||
:is-indeterminate="isIndeterminate"
|
||||
:handle-select="handleSelect"
|
||||
:handle-toggle="handleToggle"
|
||||
:isDragging="isDragging"
|
||||
:isDraggedOver="isDraggedOver"
|
||||
/>
|
||||
<div v-if="instruction">
|
||||
<slot name="hint" :instruction="instruction" />
|
||||
</div>
|
||||
</TreeItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { useForwardPropsEmits, type FlattenedItem, type TreeItemEmits, type TreeItemProps } from 'radix-vue';
|
||||
import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
|
||||
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
|
||||
|
||||
const props = defineProps<TreeItemProps<T> & FlattenedItem<T>>();
|
||||
const emits = defineEmits<TreeItemEmits<T>>();
|
||||
|
||||
defineSlots<{
|
||||
default: (props: {
|
||||
isExpanded: boolean
|
||||
isSelected: boolean
|
||||
isIndeterminate: boolean | undefined
|
||||
isDragging: boolean
|
||||
isDraggedOver: boolean
|
||||
handleToggle: () => void
|
||||
handleSelect: () => void
|
||||
}) => any,
|
||||
hint: (props: {
|
||||
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
|
||||
}) => any,
|
||||
}>()
|
||||
|
||||
const forward = useForwardPropsEmits(props, emits);
|
||||
|
||||
const element = templateRef('el');
|
||||
const isDragging = ref(false);
|
||||
const isDraggedOver = ref(false);
|
||||
const isInitialExpanded = ref(false);
|
||||
const instruction = ref<Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null>(null);
|
||||
|
||||
const mode = computed(() => {
|
||||
if (props.hasChildren)
|
||||
return 'expanded'
|
||||
if (props.index + 1 === props.parentItem?.children?.length)
|
||||
return 'last-in-group'
|
||||
return 'standard'
|
||||
});
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
const currentElement = unrefElement(element) as HTMLElement;
|
||||
|
||||
if (!currentElement)
|
||||
return
|
||||
|
||||
const item = { ...props.value, level: props.level, id: props._id }
|
||||
|
||||
const expandItem = () => {
|
||||
if (!element.value?.isExpanded) {
|
||||
element.value?.handleToggle()
|
||||
}
|
||||
}
|
||||
|
||||
const closeItem = () => {
|
||||
if (element.value?.isExpanded) {
|
||||
element.value?.handleToggle()
|
||||
}
|
||||
}
|
||||
|
||||
const dndFunction = combine(
|
||||
draggable({
|
||||
element: currentElement,
|
||||
getInitialData: () => item,
|
||||
onDragStart: () => {
|
||||
isDragging.value = true
|
||||
isInitialExpanded.value = element.value?.isExpanded ?? false
|
||||
closeItem()
|
||||
},
|
||||
onDrop: () => {
|
||||
isDragging.value = false
|
||||
if (isInitialExpanded.value)
|
||||
expandItem()
|
||||
},
|
||||
}),
|
||||
|
||||
dropTargetForElements({
|
||||
element: currentElement,
|
||||
getData: ({ input, element }) => {
|
||||
const data = { id: item.id }
|
||||
|
||||
return attachInstruction(data, {
|
||||
input,
|
||||
element,
|
||||
indentPerLevel: 16,
|
||||
currentLevel: props.level,
|
||||
mode: mode.value,
|
||||
block: [],
|
||||
})
|
||||
},
|
||||
canDrop: ({ source }) => {
|
||||
return source.data.id !== item.id
|
||||
},
|
||||
onDrag: ({ self }) => {
|
||||
instruction.value = extractInstruction(self.data) as typeof instruction.value
|
||||
},
|
||||
onDragEnter: ({ source }) => {
|
||||
if (source.data.id !== item.id) {
|
||||
isDraggedOver.value = true
|
||||
}
|
||||
},
|
||||
onDragLeave: () => {
|
||||
isDraggedOver.value = false
|
||||
instruction.value = null
|
||||
},
|
||||
onDrop: ({ location }) => {
|
||||
isDraggedOver.value = false
|
||||
instruction.value = null
|
||||
},
|
||||
getIsSticky: () => true,
|
||||
}),
|
||||
|
||||
monitorForElements({
|
||||
canMonitor: ({ source }) => {
|
||||
return source.data.id !== item.id
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Cleanup dnd function
|
||||
onCleanup(() => dndFunction())
|
||||
})
|
||||
</script>
|
||||
|
|
@ -84,10 +84,10 @@ export const characterChoicesTable = table("character_choices", {
|
|||
export const campaignTable = table("campaign", {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
link: text().notNull(),
|
||||
status: text({ enum: ['PREPARING', 'PLAYING', 'ARCHIVED'] }).default('PREPARING'),
|
||||
settings: text({ mode: 'json' }).default('{}'),
|
||||
inventory: text({ mode: 'json' }).default('[]'),
|
||||
money: int().default(0),
|
||||
public_notes: text().default(''),
|
||||
|
|
@ -103,11 +103,11 @@ export const campaignCharactersTable = table("campaign_characters", {
|
|||
}, (table) => [primaryKey({ columns: [table.id, table.character] })]);
|
||||
export const campaignLogsTable = table("campaign_logs", {
|
||||
id: int().references(() => campaignTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
from: int().references(() => campaignCharactersTable.character, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
target: int().references(() => campaignCharactersTable.character, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
timestamp: int({ mode: 'timestamp_ms' }).notNull(),
|
||||
type: text({ enum: ['ITEM', 'CHARACTER', 'PLACE', 'EVENT', 'FIGHT', 'TEXT'] }),
|
||||
details: text().notNull(),
|
||||
}, (table) => [primaryKey({ columns: [table.id, table.from, table.timestamp] })]);
|
||||
}, (table) => [primaryKey({ columns: [table.id, table.target, table.timestamp] })]);
|
||||
|
||||
export const usersRelation = relations(usersTable, ({ one, many }) => ({
|
||||
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
|
||||
|
|
@ -166,5 +166,5 @@ export const campaignCharacterRelation = relations(campaignCharactersTable, ({ o
|
|||
}));
|
||||
export const campaignLogsRelation = relations(campaignLogsTable, ({ one }) => ({
|
||||
campaign: one(campaignTable, { fields: [campaignLogsTable.id], references: [campaignTable.id], }),
|
||||
character: one(campaignCharactersTable, { fields: [campaignLogsTable.from], references: [campaignCharactersTable.character], }),
|
||||
character: one(campaignCharactersTable, { fields: [campaignLogsTable.target], references: [campaignCharactersTable.character], }),
|
||||
}));
|
||||
|
|
@ -16,6 +16,10 @@ onMounted(() => {
|
|||
{
|
||||
const campaign = new CampaignSheet(id, user);
|
||||
container.value.appendChild(campaign.container);
|
||||
|
||||
onUnmounted(() => {
|
||||
campaign.ws?.close();
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ function create()
|
|||
{
|
||||
useRequestFetch()('/api/campaign', {
|
||||
method: 'POST',
|
||||
body: { id: 'new', name: 'Test', description: '', joinby: 'link' },
|
||||
body: { name: 'Margooning', public_notes: '', dm_notes: '', settings: {} },
|
||||
}).then(() => Toaster.add({ duration: 8000, content: 'Campagne créée', type: 'info' })).catch((e) => Toaster.add({ duration: 8000, title: 'Une erreur est survenue', content: e, type: 'error' }))
|
||||
}
|
||||
</script>
|
||||
|
|
@ -52,6 +52,7 @@ function create()
|
|||
<Loading size="large" />
|
||||
</div>
|
||||
<template v-else-if="status === 'success' && loggedIn && user">
|
||||
<div class="flex flex-row items-center w-full"><Button @click="() => create()">Nouvelle campagne</Button></div>
|
||||
<div v-if="campaigns && campaigns.length > 0" class="flex flex-col gap-4">
|
||||
<div v-if="valids && valids.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="campaign of valids">
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ onMounted(() => {
|
|||
{
|
||||
const character = new CharacterSheet(id, user);
|
||||
container.value.appendChild(character.container);
|
||||
|
||||
onUnmounted(() => {
|
||||
character.ws?.close();
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export type Campaign = {
|
|||
logs: CampaignLog[];
|
||||
} & CampaignVariables;
|
||||
export type CampaignLog = {
|
||||
from: number;
|
||||
target: number;
|
||||
timestamp: Serialize<Date>;
|
||||
type: 'ITEM' | 'CHARACTER' | 'PLACE' | 'EVENT' | 'FIGHT' | 'TEXT';
|
||||
details: string;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ export type Character = {
|
|||
owner: number;
|
||||
username?: string;
|
||||
visibility: "private" | "public";
|
||||
|
||||
campaign?: number;
|
||||
};
|
||||
export type CharacterVariables = {
|
||||
health: number;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { defineConfig } from 'drizzle-kit';
|
|||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './db/schema.ts',
|
||||
schema: './app/db/schema.ts',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
url: process.env.DB_FILE!,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_campaign` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`owner` integer NOT NULL,
|
||||
`link` text NOT NULL,
|
||||
`status` text DEFAULT 'PREPARING',
|
||||
`inventory` text DEFAULT '[]',
|
||||
`money` integer DEFAULT 0,
|
||||
`public_notes` text DEFAULT '',
|
||||
`dm_notes` text DEFAULT '',
|
||||
FOREIGN KEY (`owner`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_campaign`("id", "name", "owner", "link", "status", "inventory", "money", "public_notes", "dm_notes") SELECT "id", "name", "owner", "link", "status", "inventory", "money", "public_notes", "dm_notes" FROM `campaign`;--> statement-breakpoint
|
||||
DROP TABLE `campaign`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_campaign` RENAME TO `campaign`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
ALTER TABLE `campaign_members` DROP COLUMN `rights`;
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_campaign_logs` (
|
||||
`id` integer,
|
||||
`target` integer,
|
||||
`timestamp` integer NOT NULL,
|
||||
`type` text,
|
||||
`details` text NOT NULL,
|
||||
PRIMARY KEY(`id`, `target`, `timestamp`),
|
||||
FOREIGN KEY (`id`) REFERENCES `campaign`(`id`) ON UPDATE cascade ON DELETE cascade,
|
||||
FOREIGN KEY (`target`) REFERENCES `campaign_characters`(`character`) ON UPDATE cascade ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_campaign_logs`("id", "target", "timestamp", "type", "details") SELECT "id", "target", "timestamp", "type", "details" FROM `campaign_logs`;--> statement-breakpoint
|
||||
DROP TABLE `campaign_logs`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_campaign_logs` RENAME TO `campaign_logs`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
ALTER TABLE `campaign` ADD `settings` text DEFAULT '{}';
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -162,6 +162,20 @@
|
|||
"when": 1762948869537,
|
||||
"tag": "0022_warm_bushwacker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "6",
|
||||
"when": 1763462411934,
|
||||
"tag": "0023_chunky_thunderbird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "6",
|
||||
"when": 1763479527696,
|
||||
"tag": "0024_secret_arclight",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -172,7 +172,6 @@ export default defineNuxtConfig({
|
|||
sources: ['/api/__sitemap__/urls']
|
||||
},
|
||||
experimental: {
|
||||
buildCache: true,
|
||||
componentIslands: {
|
||||
selectiveClient: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod/v4';
|
||||
import useDatabase from '~/composables/useDatabase';
|
||||
import { campaignMembersTable, campaignTable } from '~/db/schema';
|
||||
import { campaignTable } from '~/db/schema';
|
||||
import { CampaignValidation } from '#shared/campaign.util';
|
||||
import { cryptURI } from '#shared/general.util';
|
||||
|
||||
|
|
@ -26,13 +26,15 @@ export default defineEventHandler(async (e) => {
|
|||
const id = db.transaction((tx) => {
|
||||
const id = tx.insert(campaignTable).values({
|
||||
name: body.data.name,
|
||||
description: body.data.description,
|
||||
public_notes: body.data.public_notes,
|
||||
owner: session.user!.id,
|
||||
dm_notes: body.data.dm_notes,
|
||||
settings: body.data.settings,
|
||||
link: '',
|
||||
}).returning({ id: campaignTable.id }).get().id;
|
||||
|
||||
tx.update(campaignTable).set({ link: cryptURI('campaign', id) }).where(eq(campaignTable.id, id)).run();
|
||||
|
||||
|
||||
return id;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default defineEventHandler(async (e) => {
|
|||
members: { with: { member: { columns: { username: true, id: true } } }, columns: { id: false, user: false } },
|
||||
characters: { with: { character: { columns: { id: true, name: true, owner: true } } }, columns: { character: false } },
|
||||
owner: { columns: { username: true, id: true } },
|
||||
logs: { columns: { details: true, from: true, timestamp: true, type: true }, orderBy: ({ timestamp }) => timestamp },
|
||||
logs: { columns: { details: true, target: true, timestamp: true, type: true }, orderBy: ({ timestamp }) => timestamp },
|
||||
},
|
||||
where: ({ id: _id }) => eq(_id, parseInt(id, 10)),
|
||||
}).sync();
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ export default defineEventHandler(async (e) => {
|
|||
db.transaction((tx) => {
|
||||
tx.update(campaignTable).set({
|
||||
name: body.data.name,
|
||||
description: body.data.description,
|
||||
public_notes: body.data.public_notes,
|
||||
dm_notes: body.data.dm_notes,
|
||||
settings: body.data.settings,
|
||||
}).where(eq(campaignTable.id, id)).run();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default defineEventHandler(async (e) => {
|
|||
columns: { username: true }
|
||||
},
|
||||
campaign: {
|
||||
columns: { character: false, id: false, },
|
||||
columns: { character: false, id: true, },
|
||||
with: {
|
||||
campaign: {
|
||||
columns: { owner: true, },
|
||||
|
|
@ -70,6 +70,8 @@ export default defineEventHandler(async (e) => {
|
|||
owner: character.owner,
|
||||
username: character.user.username,
|
||||
visibility: character.visibility,
|
||||
|
||||
campaign: character.campaign?.id,
|
||||
} as Character;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,36 @@
|
|||
const TRIGGER_CHAR = {
|
||||
PING: String.fromCharCode(0x02),
|
||||
PONG: String.fromCharCode(0x03),
|
||||
STATUS: String.fromCharCode(0x04),
|
||||
};
|
||||
import type { SocketMessage } from "#shared/websocket.util";
|
||||
import type { User } from "~/types/auth";
|
||||
|
||||
export default defineWebSocketHandler({
|
||||
message(peer, message) {
|
||||
switch(message.rawData)
|
||||
const data = message.json<SocketMessage>();
|
||||
switch(data.type)
|
||||
{
|
||||
case TRIGGER_CHAR.PING:
|
||||
peer.send(TRIGGER_CHAR.PONG);
|
||||
return;
|
||||
default:
|
||||
case 'PING':
|
||||
peer.send(JSON.stringify({ type: 'PONG' }));
|
||||
return;
|
||||
|
||||
default: return;
|
||||
}
|
||||
},
|
||||
open(peer) {
|
||||
async open(peer) {
|
||||
const id = new URL(peer.request.url).pathname.split('/').slice(-1)[0];
|
||||
if(!id) return peer.close();
|
||||
peer.subscribe(`campaigns/${id}`);
|
||||
peer.publish(`campaigns/${id}`, `${TRIGGER_CHAR.STATUS}`);
|
||||
|
||||
const session = await getUserSession(peer);
|
||||
if(!session ||!session.user) return peer.close();
|
||||
peer.context.user = session.user;
|
||||
|
||||
const topic = `campaigns/${id}`;
|
||||
peer.subscribe(topic);
|
||||
peer.publish(topic, { type: 'user', data: [{ user: (peer.context.user as User).id, status: true }] });
|
||||
peer.send({ type: 'user', data: peer.peers.values().filter(e => e.topics.has(topic)).map(e => ({ user: (e.context.user as User).id, status: true })).toArray() })
|
||||
},
|
||||
close(peer, details) {
|
||||
const id = new URL(peer.request.url).pathname.split('/').slice(-1)[0];
|
||||
if(!id) return peer.close();
|
||||
peer.publish(`campaigns/${id}`, false);
|
||||
|
||||
peer.publish(`campaigns/${id}`, { type: 'user', data: [{ user: (peer.context.user as User).id, status: false }] });
|
||||
peer.unsubscribe(`campaigns/${id}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import { defu } from 'defu'
|
|||
import { createHooks } from 'hookable'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import type { UserSession, UserSessionRequired } from '~/types/auth'
|
||||
import type { CompatEvent } from '~~/shared/websocket.util'
|
||||
|
||||
export interface SessionHooks {
|
||||
/**
|
||||
|
|
@ -11,11 +12,11 @@ export interface SessionHooks {
|
|||
* - Add extra properties to the session
|
||||
* - Throw an error if the session could not be verified (with a database for example)
|
||||
*/
|
||||
fetch: (session: UserSessionRequired, event: H3Event) => void | Promise<void>
|
||||
fetch: (session: UserSessionRequired, event: H3Event | CompatEvent) => void | Promise<void>
|
||||
/**
|
||||
* Called before clearing the session
|
||||
*/
|
||||
clear: (session: UserSession, event: H3Event) => void | Promise<void>
|
||||
clear: (session: UserSession, event: H3Event | CompatEvent) => void | Promise<void>
|
||||
}
|
||||
|
||||
export const sessionHooks = createHooks<SessionHooks>()
|
||||
|
|
@ -25,7 +26,7 @@ export const sessionHooks = createHooks<SessionHooks>()
|
|||
* @param event The Request (h3) event
|
||||
* @returns The user session
|
||||
*/
|
||||
export async function getUserSession(event: H3Event) {
|
||||
export async function getUserSession(event: H3Event | CompatEvent) {
|
||||
const session = await _useSession(event);
|
||||
|
||||
if(!session.data || !session.data.id)
|
||||
|
|
@ -41,7 +42,7 @@ export async function getUserSession(event: H3Event) {
|
|||
* @param data User session data, please only store public information since it can be decoded with API calls
|
||||
* @see https://github.com/atinux/nuxt-auth-utils
|
||||
*/
|
||||
export async function setUserSession(event: H3Event, data: UserSession) {
|
||||
export async function setUserSession(event: H3Event | CompatEvent, data: UserSession) {
|
||||
const session = await _useSession(event)
|
||||
|
||||
await session.update(defu(data, session.data))
|
||||
|
|
@ -54,7 +55,7 @@ export async function setUserSession(event: H3Event, data: UserSession) {
|
|||
* @param event The Request (h3) event
|
||||
* @param data User session data, please only store public information since it can be decoded with API calls
|
||||
*/
|
||||
export async function replaceUserSession(event: H3Event, data: UserSession) {
|
||||
export async function replaceUserSession(event: H3Event | CompatEvent, data: UserSession) {
|
||||
const session = await _useSession(event)
|
||||
|
||||
await session.clear()
|
||||
|
|
@ -68,7 +69,7 @@ export async function replaceUserSession(event: H3Event, data: UserSession) {
|
|||
* @param event The Request (h3) event
|
||||
* @returns true if the session was cleared
|
||||
*/
|
||||
export async function clearUserSession(event: H3Event) {
|
||||
export async function clearUserSession(event: H3Event | CompatEvent) {
|
||||
const session = await _useSession(event)
|
||||
|
||||
await sessionHooks.callHookParallel('clear', session.data, event)
|
||||
|
|
@ -85,7 +86,7 @@ export async function clearUserSession(event: H3Event) {
|
|||
* @param opts.message The message to use for the error (defaults to Unauthorized)
|
||||
* @see https://github.com/atinux/nuxt-auth-utils
|
||||
*/
|
||||
export async function requireUserSession(event: H3Event, opts: { statusCode?: number, message?: string } = {}): Promise<UserSessionRequired> {
|
||||
export async function requireUserSession(event: H3Event | CompatEvent, opts: { statusCode?: number, message?: string } = {}): Promise<UserSessionRequired> {
|
||||
const userSession = await getUserSession(event)
|
||||
|
||||
if (!userSession.user) {
|
||||
|
|
@ -100,9 +101,9 @@ export async function requireUserSession(event: H3Event, opts: { statusCode?: nu
|
|||
|
||||
let sessionConfig: SessionConfig
|
||||
|
||||
function _useSession(event: H3Event) {
|
||||
if (!sessionConfig) {
|
||||
const runtimeConfig = useRuntimeConfig(event)
|
||||
function _useSession(event: H3Event | CompatEvent) {
|
||||
if (!sessionConfig && '__is_event__' in event) {
|
||||
const runtimeConfig = useRuntimeConfig(event);
|
||||
|
||||
sessionConfig = runtimeConfig.session;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@ import { tooltip } from "#shared/floating.util";
|
|||
import markdown from "#shared/markdown.util";
|
||||
import { preview } from "./proses";
|
||||
import { format } from "./general.util";
|
||||
import { Socket } from "#shared/websocket.util";
|
||||
|
||||
export const CampaignValidation = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().nonempty(),
|
||||
description: z.string()
|
||||
public_notes: z.string(),
|
||||
dm_notes: z.string(),
|
||||
settings: z.object(),
|
||||
});
|
||||
|
||||
class CharacterPrinter
|
||||
|
|
@ -41,29 +44,39 @@ class CharacterPrinter
|
|||
}
|
||||
}
|
||||
type PlayerState = {
|
||||
status: boolean;
|
||||
statusDOM: HTMLElement;
|
||||
statusTooltip: Text;
|
||||
dom: HTMLElement;
|
||||
user: { id: number, username: string };
|
||||
};
|
||||
const activity = {
|
||||
online: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-green dark:bg-dark-green border-light-green dark:border-dark-green', text: 'En ligne' },
|
||||
afk: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-yellow dark:bg-dark-yellow border-light-yellow dark:border-dark-yellow', text: 'Inactif' },
|
||||
offline: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content border-dashed border-light-50 dark:border-dark-50 bg-light-0 dark:bg-dark-0', text: 'Hors ligne' },
|
||||
}
|
||||
function defaultPlayerState(user: { id: number, username: string }): PlayerState
|
||||
{
|
||||
const statusTooltip = text('Absent');
|
||||
const statusTooltip = text(activity.offline.text), statusDOM = span(activity.offline.class);
|
||||
return {
|
||||
status: false,
|
||||
statusDOM: tooltip(span('rounded-full w-3 h-3 block border-light-50 dark:border-dark-50 border-2 border-dashed'), statusTooltip, 'right'),
|
||||
statusDOM,
|
||||
statusTooltip,
|
||||
dom: div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), user.username, 'bottom'), tooltip(statusDOM, statusTooltip, 'bottom') ]),
|
||||
user
|
||||
}
|
||||
}
|
||||
export class CampaignSheet
|
||||
{
|
||||
user: ComputedRef<User | null>;
|
||||
campaign?: Campaign;
|
||||
container: HTMLElement = div('flex flex-col flex-1 h-full w-full items-center justify-start');
|
||||
dm!: PlayerState;
|
||||
players!: Array<PlayerState>;
|
||||
characters!: Array<CharacterPrinter>;
|
||||
private user: ComputedRef<User | null>;
|
||||
private campaign?: Campaign;
|
||||
|
||||
container: HTMLElement = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6');
|
||||
|
||||
private dm!: PlayerState;
|
||||
private players!: Array<PlayerState>;
|
||||
private characters!: Array<CharacterPrinter>;
|
||||
|
||||
ws?: Socket;
|
||||
|
||||
constructor(id: string, user: ComputedRef<User | null>)
|
||||
{
|
||||
this.user = user;
|
||||
|
|
@ -76,6 +89,26 @@ export class CampaignSheet
|
|||
this.dm = defaultPlayerState(campaign.owner);
|
||||
this.players = campaign.members.map(e => defaultPlayerState(e.member));
|
||||
this.characters = campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name));
|
||||
this.ws = new Socket(`/ws/campaign/${id}`, true);
|
||||
this.ws.handleMessage<{ user: number, status: boolean }[]>('user', (users) => {
|
||||
users.forEach(user => {
|
||||
if(this.dm.user.id === user.user)
|
||||
{
|
||||
this.dm.statusTooltip.textContent = activity[user.status ? 'online' : 'offline'].text;
|
||||
this.dm.statusDOM.className = activity[user.status ? 'online' : 'offline'].class;
|
||||
}
|
||||
else
|
||||
{
|
||||
const player = this.players.find(e => e.user.id === user.user)
|
||||
|
||||
if(player)
|
||||
{
|
||||
player.statusTooltip.textContent = activity[user.status ? 'online' : 'offline'].text;
|
||||
player.statusDOM.className = activity[user.status ? 'online' : 'offline'].class;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
document.title = `d[any] - Campagne ${campaign.name}`;
|
||||
this.render();
|
||||
|
|
@ -102,11 +135,11 @@ export class CampaignSheet
|
|||
if(!campaign)
|
||||
return;
|
||||
|
||||
this.container.replaceChildren(div('grid grid-cols-3 gap-2 py-4', [
|
||||
this.container.replaceChildren(div('grid grid-cols-3 gap-2', [
|
||||
div('flex flex-row gap-2 items-center py-2', [
|
||||
tooltip(div('w-8 h-8 border border-light-40 dark:border-dark-40 box-content rounded-full'), this.dm.user.username, "bottom"),
|
||||
this.dm.dom,
|
||||
div('border-l h-full w-0 border-light-40 dark:border-dark-40'),
|
||||
div('flex flex-row gap-1', this.players.map(e => tooltip(div('w-8 h-8 border border-light-40 dark:border-dark-40 box-content rounded-full'), e.user.username, "bottom"))),
|
||||
div('flex flex-row gap-1', this.players.map(e => e.dom)),
|
||||
]),
|
||||
div('flex flex-1 flex-col items-center justify-center gap-2', [
|
||||
span('text-2xl font-serif font-bold italic', campaign.name),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import markdown from "#shared/markdown.util";
|
|||
import { getText } from "#shared/i18n";
|
||||
import type { User } from "~/types/auth";
|
||||
import { MarkdownEditor } from "#shared/editor.util";
|
||||
import { Socket } from "#shared/websocket.util";
|
||||
|
||||
const config = characterConfig as CharacterConfig;
|
||||
|
||||
|
|
@ -1293,10 +1294,12 @@ const subnameFactory = (item: ItemConfig, state?: ItemState): string[] => {
|
|||
}
|
||||
export class CharacterSheet
|
||||
{
|
||||
user: ComputedRef<User | null>;
|
||||
character?: CharacterCompiler;
|
||||
private user: ComputedRef<User | null>;
|
||||
private character?: CharacterCompiler;
|
||||
container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center');
|
||||
tabs?: HTMLDivElement & { refresh: () => void };
|
||||
private tabs?: HTMLDivElement & { refresh: () => void };
|
||||
|
||||
ws?: Socket;
|
||||
constructor(id: string, user: ComputedRef<User | null>)
|
||||
{
|
||||
this.user = user;
|
||||
|
|
@ -1307,6 +1310,11 @@ export class CharacterSheet
|
|||
{
|
||||
this.character = new CharacterCompiler(character);
|
||||
|
||||
if(character.campaign)
|
||||
{
|
||||
this.ws = new Socket(`/ws/campaign/${character.campaign}`, true);
|
||||
}
|
||||
|
||||
document.title = `d[any] - ${character.name}`;
|
||||
load.remove();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const ID_SIZE = 32;
|
||||
const ID_SIZE = 24;
|
||||
const URLSafeCharacters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.~';
|
||||
|
||||
export function unifySlug(slug: string | string[]): string
|
||||
{
|
||||
|
|
@ -7,9 +8,39 @@ export function unifySlug(slug: string | string[]): string
|
|||
export function getID()
|
||||
{
|
||||
for (var id = [], i = 0; i < ID_SIZE; i++)
|
||||
id.push((36 * Math.random() | 0).toString(36));
|
||||
id.push(URLSafeCharacters[URLSafeCharacters.length * Math.random() | 0]);
|
||||
return id.join("");
|
||||
}
|
||||
export function encodeBase(value: string): string
|
||||
{
|
||||
if(value === '')
|
||||
return '';
|
||||
|
||||
const buffer = Buffer.from(value, 'utf8'), base = BigInt(URLSafeCharacters.length);
|
||||
let nb = BigInt('0x' + buffer.toHex()), result = [];
|
||||
|
||||
while(nb > 0)
|
||||
{
|
||||
const remaining = nb % base;
|
||||
result.push(URLSafeCharacters[Number(remaining)]);
|
||||
nb = (nb - remaining) / base;
|
||||
}
|
||||
|
||||
const text = result.reverse().join('');
|
||||
return text;
|
||||
}
|
||||
export function decodeBase(value: string): string
|
||||
{
|
||||
if(value === '')
|
||||
return '';
|
||||
|
||||
const result = '', base = BigInt(URLSafeCharacters.length);
|
||||
let nb = BigInt(0);
|
||||
value.split('').forEach(e => nb = nb * base + BigInt(URLSafeCharacters.indexOf(e)));
|
||||
|
||||
const text = Buffer.from(nb.toString(16), 'hex').toString('utf8');
|
||||
return text;
|
||||
}
|
||||
export function group<
|
||||
T,
|
||||
K extends keyof T,
|
||||
|
|
@ -56,7 +87,7 @@ export function format(date: Date, template: string): string
|
|||
|
||||
for(const key of keys)
|
||||
{
|
||||
template = template.replaceAll(key, () => transforms[key]!(date));
|
||||
template = key in transforms ? template.replaceAll(key, () => transforms[key]!(date)) : template;
|
||||
}
|
||||
|
||||
return template;
|
||||
|
|
@ -70,33 +101,29 @@ export function lerp(x: number, a: number, b: number): number
|
|||
return (1-x)*a+x*b;
|
||||
}
|
||||
// The value position is randomized
|
||||
// The metadata separator is randomized as a letter (to avoid collision with numbers)
|
||||
// The URI is (| == picked separator) |v_length|first part of the hash + seed + second part of the hash|v_pos|seed as hex.
|
||||
// Every number are converted to string as hexadecimal values
|
||||
export function cryptURI(key: string, value: number, seed?: number): string
|
||||
// The metadata separator is randomized from the URLSafeCharacters set
|
||||
// The URI is (| == picked separator) |v_length|first part of the hash + value + second part of the hash|v_pos as hex.
|
||||
export function cryptURI(key: string, value: number): string
|
||||
{
|
||||
const _seed = seed ?? Date.now();
|
||||
const hash = Bun.hash(key + value.toString(), _seed).toString(16);
|
||||
const hash = Bun.hash.crc32(key + value.toString()).toString(16);
|
||||
const pos = Math.floor(Math.random() * hash.length);
|
||||
const separator = String.fromCharCode(Math.floor(Math.random() * 26 + 96));
|
||||
const separator = URLSafeCharacters[URLSafeCharacters.length * Math.random() | 0]!;
|
||||
|
||||
return Bun.zstdCompressSync(separator + value.toString(16).length + separator + hash.substring(0, pos) + value + hash.substring(pos) + separator + pos + separator + _seed.toString(16), { level: 1 }).toString('base64');
|
||||
return encodeBase(separator + value.toString().length + separator + hash.substring(0, pos) + value + hash.substring(pos) + separator + pos);
|
||||
}
|
||||
export function decryptURI(uri: string, key: string): number | undefined
|
||||
{
|
||||
const decoder = new TextDecoder();
|
||||
const _uri = decoder.decode(Bun.zstdDecompressSync(Buffer.from(uri, 'base64')));
|
||||
|
||||
const _uri = decodeBase(uri);
|
||||
|
||||
const separator = _uri.charAt(0);
|
||||
const length = parseInt(_uri.substring(1, _uri.indexOf(separator, 1)), 10);
|
||||
const seed = parseInt(_uri.substring(_uri.lastIndexOf(separator) + 1), 16);
|
||||
const pos = parseInt(_uri.substring(_uri.lastIndexOf(separator, _uri.length - seed.toString(16).length - 2) + 1, _uri.lastIndexOf(separator)), 10);
|
||||
const _hash = _uri.substring(2 + length.toString(10).length, _uri.length - (2 + seed.toString(16).length + pos.toString(10).length));
|
||||
const pos = parseInt(_uri.substring(_uri.lastIndexOf(separator) + 1), 16);
|
||||
const _hash = _uri.substring(_uri.lastIndexOf(separator, _uri.length - pos.toString(16).length - 2) + 1, _uri.lastIndexOf(separator));
|
||||
|
||||
const value = _hash.substring(pos, pos + length);
|
||||
const hash = _hash.substring(0, pos) + _hash.substring(pos + length);
|
||||
|
||||
if(Bun.hash(key + value, seed).toString(16) === hash)
|
||||
if(Bun.hash.crc32(key + value).toString(16) === hash)
|
||||
return parseInt(value, 10);
|
||||
else
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
export type SocketMessage = {
|
||||
type: string;
|
||||
data: any;
|
||||
}
|
||||
export type CompatEvent = {
|
||||
request: {
|
||||
headers: Headers;
|
||||
};
|
||||
context: any;
|
||||
} | {
|
||||
headers: Headers;
|
||||
context: any;
|
||||
};
|
||||
|
||||
export class Socket
|
||||
{
|
||||
private _frequency?: number;
|
||||
private _timeout?: number;
|
||||
private _heartbeat: boolean = false;
|
||||
private _heartbeatWaiting = false;
|
||||
private _timeoutTimer?: NodeJS.Timeout;
|
||||
|
||||
private _ws: WebSocket;
|
||||
private _handlers: Map<string, (data: any) => void> = new Map();
|
||||
|
||||
constructor(url: string, heartbeat?: true | { timeout?: number, frequency?: number })
|
||||
{
|
||||
this._frequency = heartbeat === true ? 10000 : heartbeat?.frequency ?? 10000;
|
||||
this._timeout = heartbeat === true ? 100000 : heartbeat?.timeout ?? 100000;
|
||||
this._heartbeat = heartbeat !== undefined;
|
||||
|
||||
this._ws = new WebSocket(`${location.protocol.endsWith('s:') ? 'wss' : 'ws'}://${location.host}${url.startsWith('/') ? url : '/' + url}`);
|
||||
|
||||
this._ws.addEventListener('open', (e) => {
|
||||
console.log(`[ws] Connected to ${this._ws.url}`);
|
||||
this._heartbeat && setTimeout(() => this.heartbeat(), this._frequency);
|
||||
});
|
||||
this._ws.addEventListener('close', (e) => {
|
||||
console.log(`[ws] Disconnected from ${this._ws.url} (code: ${e.code}, reason: ${e.reason}, ${e.wasClean ? 'clean close' : 'dirty close'})`)
|
||||
this._heartbeatWaiting = false;
|
||||
this._timeoutTimer && clearTimeout(this._timeoutTimer);
|
||||
});
|
||||
this._ws.addEventListener('message', (e) => {
|
||||
const data = JSON.parse(e.data) as SocketMessage;
|
||||
|
||||
switch(data.type)
|
||||
{
|
||||
case 'PONG':
|
||||
if(this._heartbeatWaiting)
|
||||
{
|
||||
this._heartbeatWaiting = false;
|
||||
this._timeoutTimer && clearTimeout(this._timeoutTimer);
|
||||
this._heartbeat && setTimeout(() => this.heartbeat(), this._frequency);
|
||||
}
|
||||
return;
|
||||
default: return this._handlers.has(data.type) && queueMicrotask(() => this._handlers.get(data.type)!(data.data));
|
||||
}
|
||||
});
|
||||
}
|
||||
public handleMessage<T>(type: string, callback: (data: T) => void)
|
||||
{
|
||||
this._handlers.set(type, callback);
|
||||
}
|
||||
public close()
|
||||
{
|
||||
this._ws.close(1000);
|
||||
}
|
||||
private heartbeat()
|
||||
{
|
||||
if(this._heartbeat && this._ws.readyState === WebSocket.OPEN)
|
||||
{
|
||||
this._ws.send(JSON.stringify({ type: 'PING' }));
|
||||
this._heartbeatWaiting = true;
|
||||
this._timeoutTimer = setTimeout(() => this._ws.close(3000, 'Timeout'), this._timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue