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", {
|
export const campaignTable = table("campaign", {
|
||||||
id: int().primaryKey({ autoIncrement: true }),
|
id: int().primaryKey({ autoIncrement: true }),
|
||||||
name: text().notNull(),
|
name: text().notNull(),
|
||||||
description: text(),
|
|
||||||
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||||
link: text().notNull(),
|
link: text().notNull(),
|
||||||
status: text({ enum: ['PREPARING', 'PLAYING', 'ARCHIVED'] }).default('PREPARING'),
|
status: text({ enum: ['PREPARING', 'PLAYING', 'ARCHIVED'] }).default('PREPARING'),
|
||||||
|
settings: text({ mode: 'json' }).default('{}'),
|
||||||
inventory: text({ mode: 'json' }).default('[]'),
|
inventory: text({ mode: 'json' }).default('[]'),
|
||||||
money: int().default(0),
|
money: int().default(0),
|
||||||
public_notes: text().default(''),
|
public_notes: text().default(''),
|
||||||
|
|
@ -103,11 +103,11 @@ export const campaignCharactersTable = table("campaign_characters", {
|
||||||
}, (table) => [primaryKey({ columns: [table.id, table.character] })]);
|
}, (table) => [primaryKey({ columns: [table.id, table.character] })]);
|
||||||
export const campaignLogsTable = table("campaign_logs", {
|
export const campaignLogsTable = table("campaign_logs", {
|
||||||
id: int().references(() => campaignTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
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(),
|
timestamp: int({ mode: 'timestamp_ms' }).notNull(),
|
||||||
type: text({ enum: ['ITEM', 'CHARACTER', 'PLACE', 'EVENT', 'FIGHT', 'TEXT'] }),
|
type: text({ enum: ['ITEM', 'CHARACTER', 'PLACE', 'EVENT', 'FIGHT', 'TEXT'] }),
|
||||||
details: text().notNull(),
|
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 }) => ({
|
export const usersRelation = relations(usersTable, ({ one, many }) => ({
|
||||||
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
|
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 }) => ({
|
export const campaignLogsRelation = relations(campaignLogsTable, ({ one }) => ({
|
||||||
campaign: one(campaignTable, { fields: [campaignLogsTable.id], references: [campaignTable.id], }),
|
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);
|
const campaign = new CampaignSheet(id, user);
|
||||||
container.value.appendChild(campaign.container);
|
container.value.appendChild(campaign.container);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
campaign.ws?.close();
|
||||||
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ function create()
|
||||||
{
|
{
|
||||||
useRequestFetch()('/api/campaign', {
|
useRequestFetch()('/api/campaign', {
|
||||||
method: 'POST',
|
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' }))
|
}).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>
|
</script>
|
||||||
|
|
@ -52,6 +52,7 @@ function create()
|
||||||
<Loading size="large" />
|
<Loading size="large" />
|
||||||
</div>
|
</div>
|
||||||
<template v-else-if="status === 'success' && loggedIn && user">
|
<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="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 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">
|
<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);
|
const character = new CharacterSheet(id, user);
|
||||||
container.value.appendChild(character.container);
|
container.value.appendChild(character.container);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
character.ws?.close();
|
||||||
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export type Campaign = {
|
||||||
logs: CampaignLog[];
|
logs: CampaignLog[];
|
||||||
} & CampaignVariables;
|
} & CampaignVariables;
|
||||||
export type CampaignLog = {
|
export type CampaignLog = {
|
||||||
from: number;
|
target: number;
|
||||||
timestamp: Serialize<Date>;
|
timestamp: Serialize<Date>;
|
||||||
type: 'ITEM' | 'CHARACTER' | 'PLACE' | 'EVENT' | 'FIGHT' | 'TEXT';
|
type: 'ITEM' | 'CHARACTER' | 'PLACE' | 'EVENT' | 'FIGHT' | 'TEXT';
|
||||||
details: string;
|
details: string;
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ export type Character = {
|
||||||
owner: number;
|
owner: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
visibility: "private" | "public";
|
visibility: "private" | "public";
|
||||||
|
|
||||||
|
campaign?: number;
|
||||||
};
|
};
|
||||||
export type CharacterVariables = {
|
export type CharacterVariables = {
|
||||||
health: number;
|
health: number;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
schema: './db/schema.ts',
|
schema: './app/db/schema.ts',
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DB_FILE!,
|
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,
|
"when": 1762948869537,
|
||||||
"tag": "0022_warm_bushwacker",
|
"tag": "0022_warm_bushwacker",
|
||||||
"breakpoints": true
|
"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']
|
sources: ['/api/__sitemap__/urls']
|
||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
buildCache: true,
|
|
||||||
componentIslands: {
|
componentIslands: {
|
||||||
selectiveClient: true,
|
selectiveClient: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import useDatabase from '~/composables/useDatabase';
|
import useDatabase from '~/composables/useDatabase';
|
||||||
import { campaignMembersTable, campaignTable } from '~/db/schema';
|
import { campaignTable } from '~/db/schema';
|
||||||
import { CampaignValidation } from '#shared/campaign.util';
|
import { CampaignValidation } from '#shared/campaign.util';
|
||||||
import { cryptURI } from '#shared/general.util';
|
import { cryptURI } from '#shared/general.util';
|
||||||
|
|
||||||
|
|
@ -26,8 +26,10 @@ export default defineEventHandler(async (e) => {
|
||||||
const id = db.transaction((tx) => {
|
const id = db.transaction((tx) => {
|
||||||
const id = tx.insert(campaignTable).values({
|
const id = tx.insert(campaignTable).values({
|
||||||
name: body.data.name,
|
name: body.data.name,
|
||||||
description: body.data.description,
|
public_notes: body.data.public_notes,
|
||||||
owner: session.user!.id,
|
owner: session.user!.id,
|
||||||
|
dm_notes: body.data.dm_notes,
|
||||||
|
settings: body.data.settings,
|
||||||
link: '',
|
link: '',
|
||||||
}).returning({ id: campaignTable.id }).get().id;
|
}).returning({ id: campaignTable.id }).get().id;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export default defineEventHandler(async (e) => {
|
||||||
members: { with: { member: { columns: { username: true, id: true } } }, columns: { id: false, user: false } },
|
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 } },
|
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, target: 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();
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,9 @@ export default defineEventHandler(async (e) => {
|
||||||
db.transaction((tx) => {
|
db.transaction((tx) => {
|
||||||
tx.update(campaignTable).set({
|
tx.update(campaignTable).set({
|
||||||
name: body.data.name,
|
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();
|
}).where(eq(campaignTable.id, id)).run();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export default defineEventHandler(async (e) => {
|
||||||
columns: { username: true }
|
columns: { username: true }
|
||||||
},
|
},
|
||||||
campaign: {
|
campaign: {
|
||||||
columns: { character: false, id: false, },
|
columns: { character: false, id: true, },
|
||||||
with: {
|
with: {
|
||||||
campaign: {
|
campaign: {
|
||||||
columns: { owner: true, },
|
columns: { owner: true, },
|
||||||
|
|
@ -70,6 +70,8 @@ export default defineEventHandler(async (e) => {
|
||||||
owner: character.owner,
|
owner: character.owner,
|
||||||
username: character.user.username,
|
username: character.user.username,
|
||||||
visibility: character.visibility,
|
visibility: character.visibility,
|
||||||
|
|
||||||
|
campaign: character.campaign?.id,
|
||||||
} as Character;
|
} as Character;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,36 @@
|
||||||
const TRIGGER_CHAR = {
|
import type { SocketMessage } from "#shared/websocket.util";
|
||||||
PING: String.fromCharCode(0x02),
|
import type { User } from "~/types/auth";
|
||||||
PONG: String.fromCharCode(0x03),
|
|
||||||
STATUS: String.fromCharCode(0x04),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineWebSocketHandler({
|
export default defineWebSocketHandler({
|
||||||
message(peer, message) {
|
message(peer, message) {
|
||||||
switch(message.rawData)
|
const data = message.json<SocketMessage>();
|
||||||
|
switch(data.type)
|
||||||
{
|
{
|
||||||
case TRIGGER_CHAR.PING:
|
case 'PING':
|
||||||
peer.send(TRIGGER_CHAR.PONG);
|
peer.send(JSON.stringify({ type: 'PONG' }));
|
||||||
return;
|
|
||||||
default:
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
default: return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
open(peer) {
|
async open(peer) {
|
||||||
const id = new URL(peer.request.url).pathname.split('/').slice(-1)[0];
|
const id = new URL(peer.request.url).pathname.split('/').slice(-1)[0];
|
||||||
if(!id) return peer.close();
|
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) {
|
close(peer, details) {
|
||||||
const id = new URL(peer.request.url).pathname.split('/').slice(-1)[0];
|
const id = new URL(peer.request.url).pathname.split('/').slice(-1)[0];
|
||||||
if(!id) return peer.close();
|
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}`);
|
peer.unsubscribe(`campaigns/${id}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -4,6 +4,7 @@ import { defu } from 'defu'
|
||||||
import { createHooks } from 'hookable'
|
import { createHooks } from 'hookable'
|
||||||
import { useRuntimeConfig } from '#imports'
|
import { useRuntimeConfig } from '#imports'
|
||||||
import type { UserSession, UserSessionRequired } from '~/types/auth'
|
import type { UserSession, UserSessionRequired } from '~/types/auth'
|
||||||
|
import type { CompatEvent } from '~~/shared/websocket.util'
|
||||||
|
|
||||||
export interface SessionHooks {
|
export interface SessionHooks {
|
||||||
/**
|
/**
|
||||||
|
|
@ -11,11 +12,11 @@ export interface SessionHooks {
|
||||||
* - Add extra properties to the session
|
* - Add extra properties to the session
|
||||||
* - Throw an error if the session could not be verified (with a database for example)
|
* - 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
|
* 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>()
|
export const sessionHooks = createHooks<SessionHooks>()
|
||||||
|
|
@ -25,7 +26,7 @@ export const sessionHooks = createHooks<SessionHooks>()
|
||||||
* @param event The Request (h3) event
|
* @param event The Request (h3) event
|
||||||
* @returns The user session
|
* @returns The user session
|
||||||
*/
|
*/
|
||||||
export async function getUserSession(event: H3Event) {
|
export async function getUserSession(event: H3Event | CompatEvent) {
|
||||||
const session = await _useSession(event);
|
const session = await _useSession(event);
|
||||||
|
|
||||||
if(!session.data || !session.data.id)
|
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
|
* @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
|
* @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)
|
const session = await _useSession(event)
|
||||||
|
|
||||||
await session.update(defu(data, session.data))
|
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 event The Request (h3) event
|
||||||
* @param data User session data, please only store public information since it can be decoded with API calls
|
* @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)
|
const session = await _useSession(event)
|
||||||
|
|
||||||
await session.clear()
|
await session.clear()
|
||||||
|
|
@ -68,7 +69,7 @@ export async function replaceUserSession(event: H3Event, data: UserSession) {
|
||||||
* @param event The Request (h3) event
|
* @param event The Request (h3) event
|
||||||
* @returns true if the session was cleared
|
* @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)
|
const session = await _useSession(event)
|
||||||
|
|
||||||
await sessionHooks.callHookParallel('clear', session.data, 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)
|
* @param opts.message The message to use for the error (defaults to Unauthorized)
|
||||||
* @see https://github.com/atinux/nuxt-auth-utils
|
* @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)
|
const userSession = await getUserSession(event)
|
||||||
|
|
||||||
if (!userSession.user) {
|
if (!userSession.user) {
|
||||||
|
|
@ -100,9 +101,9 @@ export async function requireUserSession(event: H3Event, opts: { statusCode?: nu
|
||||||
|
|
||||||
let sessionConfig: SessionConfig
|
let sessionConfig: SessionConfig
|
||||||
|
|
||||||
function _useSession(event: H3Event) {
|
function _useSession(event: H3Event | CompatEvent) {
|
||||||
if (!sessionConfig) {
|
if (!sessionConfig && '__is_event__' in event) {
|
||||||
const runtimeConfig = useRuntimeConfig(event)
|
const runtimeConfig = useRuntimeConfig(event);
|
||||||
|
|
||||||
sessionConfig = runtimeConfig.session;
|
sessionConfig = runtimeConfig.session;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,14 @@ import { tooltip } from "#shared/floating.util";
|
||||||
import markdown from "#shared/markdown.util";
|
import markdown from "#shared/markdown.util";
|
||||||
import { preview } from "./proses";
|
import { preview } from "./proses";
|
||||||
import { format } from "./general.util";
|
import { format } from "./general.util";
|
||||||
|
import { Socket } from "#shared/websocket.util";
|
||||||
|
|
||||||
export const CampaignValidation = z.object({
|
export const CampaignValidation = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string().nonempty(),
|
name: z.string().nonempty(),
|
||||||
description: z.string()
|
public_notes: z.string(),
|
||||||
|
dm_notes: z.string(),
|
||||||
|
settings: z.object(),
|
||||||
});
|
});
|
||||||
|
|
||||||
class CharacterPrinter
|
class CharacterPrinter
|
||||||
|
|
@ -41,29 +44,39 @@ class CharacterPrinter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
type PlayerState = {
|
type PlayerState = {
|
||||||
status: boolean;
|
|
||||||
statusDOM: HTMLElement;
|
statusDOM: HTMLElement;
|
||||||
statusTooltip: Text;
|
statusTooltip: Text;
|
||||||
|
dom: HTMLElement;
|
||||||
user: { id: number, username: string };
|
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
|
function defaultPlayerState(user: { id: number, username: string }): PlayerState
|
||||||
{
|
{
|
||||||
const statusTooltip = text('Absent');
|
const statusTooltip = text(activity.offline.text), statusDOM = span(activity.offline.class);
|
||||||
return {
|
return {
|
||||||
status: false,
|
statusDOM,
|
||||||
statusDOM: tooltip(span('rounded-full w-3 h-3 block border-light-50 dark:border-dark-50 border-2 border-dashed'), statusTooltip, 'right'),
|
|
||||||
statusTooltip,
|
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
|
user
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class CampaignSheet
|
export class CampaignSheet
|
||||||
{
|
{
|
||||||
user: ComputedRef<User | null>;
|
private user: ComputedRef<User | null>;
|
||||||
campaign?: Campaign;
|
private campaign?: Campaign;
|
||||||
container: HTMLElement = div('flex flex-col flex-1 h-full w-full items-center justify-start');
|
|
||||||
dm!: PlayerState;
|
container: HTMLElement = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6');
|
||||||
players!: Array<PlayerState>;
|
|
||||||
characters!: Array<CharacterPrinter>;
|
private dm!: PlayerState;
|
||||||
|
private players!: Array<PlayerState>;
|
||||||
|
private characters!: Array<CharacterPrinter>;
|
||||||
|
|
||||||
|
ws?: Socket;
|
||||||
|
|
||||||
constructor(id: string, user: ComputedRef<User | null>)
|
constructor(id: string, user: ComputedRef<User | null>)
|
||||||
{
|
{
|
||||||
this.user = user;
|
this.user = user;
|
||||||
|
|
@ -76,6 +89,26 @@ export class CampaignSheet
|
||||||
this.dm = defaultPlayerState(campaign.owner);
|
this.dm = defaultPlayerState(campaign.owner);
|
||||||
this.players = campaign.members.map(e => defaultPlayerState(e.member));
|
this.players = campaign.members.map(e => defaultPlayerState(e.member));
|
||||||
this.characters = campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name));
|
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}`;
|
document.title = `d[any] - Campagne ${campaign.name}`;
|
||||||
this.render();
|
this.render();
|
||||||
|
|
@ -102,11 +135,11 @@ export class CampaignSheet
|
||||||
if(!campaign)
|
if(!campaign)
|
||||||
return;
|
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', [
|
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('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', [
|
div('flex flex-1 flex-col items-center justify-center gap-2', [
|
||||||
span('text-2xl font-serif font-bold italic', campaign.name),
|
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 { getText } from "#shared/i18n";
|
||||||
import type { User } from "~/types/auth";
|
import type { User } from "~/types/auth";
|
||||||
import { MarkdownEditor } from "#shared/editor.util";
|
import { MarkdownEditor } from "#shared/editor.util";
|
||||||
|
import { Socket } from "#shared/websocket.util";
|
||||||
|
|
||||||
const config = characterConfig as CharacterConfig;
|
const config = characterConfig as CharacterConfig;
|
||||||
|
|
||||||
|
|
@ -1293,10 +1294,12 @@ const subnameFactory = (item: ItemConfig, state?: ItemState): string[] => {
|
||||||
}
|
}
|
||||||
export class CharacterSheet
|
export class CharacterSheet
|
||||||
{
|
{
|
||||||
user: ComputedRef<User | null>;
|
private user: ComputedRef<User | null>;
|
||||||
character?: CharacterCompiler;
|
private character?: CharacterCompiler;
|
||||||
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');
|
||||||
tabs?: HTMLDivElement & { refresh: () => void };
|
private tabs?: HTMLDivElement & { refresh: () => void };
|
||||||
|
|
||||||
|
ws?: Socket;
|
||||||
constructor(id: string, user: ComputedRef<User | null>)
|
constructor(id: string, user: ComputedRef<User | null>)
|
||||||
{
|
{
|
||||||
this.user = user;
|
this.user = user;
|
||||||
|
|
@ -1307,6 +1310,11 @@ export class CharacterSheet
|
||||||
{
|
{
|
||||||
this.character = new CharacterCompiler(character);
|
this.character = new CharacterCompiler(character);
|
||||||
|
|
||||||
|
if(character.campaign)
|
||||||
|
{
|
||||||
|
this.ws = new Socket(`/ws/campaign/${character.campaign}`, true);
|
||||||
|
}
|
||||||
|
|
||||||
document.title = `d[any] - ${character.name}`;
|
document.title = `d[any] - ${character.name}`;
|
||||||
load.remove();
|
load.remove();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const ID_SIZE = 32;
|
const ID_SIZE = 24;
|
||||||
|
const URLSafeCharacters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.~';
|
||||||
|
|
||||||
export function unifySlug(slug: string | string[]): string
|
export function unifySlug(slug: string | string[]): string
|
||||||
{
|
{
|
||||||
|
|
@ -7,9 +8,39 @@ export function unifySlug(slug: string | string[]): string
|
||||||
export function getID()
|
export function getID()
|
||||||
{
|
{
|
||||||
for (var id = [], i = 0; i < ID_SIZE; i++)
|
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("");
|
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<
|
export function group<
|
||||||
T,
|
T,
|
||||||
K extends keyof T,
|
K extends keyof T,
|
||||||
|
|
@ -56,7 +87,7 @@ export function format(date: Date, template: string): string
|
||||||
|
|
||||||
for(const key of keys)
|
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;
|
return template;
|
||||||
|
|
@ -70,33 +101,29 @@ export function lerp(x: number, a: number, b: number): number
|
||||||
return (1-x)*a+x*b;
|
return (1-x)*a+x*b;
|
||||||
}
|
}
|
||||||
// The value position is randomized
|
// The value position is randomized
|
||||||
// The metadata separator is randomized as a letter (to avoid collision with numbers)
|
// The metadata separator is randomized from the URLSafeCharacters set
|
||||||
// The URI is (| == picked separator) |v_length|first part of the hash + seed + second part of the hash|v_pos|seed as hex.
|
// The URI is (| == picked separator) |v_length|first part of the hash + value + second part of the hash|v_pos as hex.
|
||||||
// Every number are converted to string as hexadecimal values
|
export function cryptURI(key: string, value: number): string
|
||||||
export function cryptURI(key: string, value: number, seed?: number): string
|
|
||||||
{
|
{
|
||||||
const _seed = seed ?? Date.now();
|
const hash = Bun.hash.crc32(key + value.toString()).toString(16);
|
||||||
const hash = Bun.hash(key + value.toString(), _seed).toString(16);
|
|
||||||
const pos = Math.floor(Math.random() * hash.length);
|
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
|
export function decryptURI(uri: string, key: string): number | undefined
|
||||||
{
|
{
|
||||||
const decoder = new TextDecoder();
|
const _uri = decodeBase(uri);
|
||||||
const _uri = decoder.decode(Bun.zstdDecompressSync(Buffer.from(uri, 'base64')));
|
|
||||||
|
|
||||||
const separator = _uri.charAt(0);
|
const separator = _uri.charAt(0);
|
||||||
const length = parseInt(_uri.substring(1, _uri.indexOf(separator, 1)), 10);
|
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) + 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(_uri.lastIndexOf(separator, _uri.length - pos.toString(16).length - 2) + 1, _uri.lastIndexOf(separator));
|
||||||
const _hash = _uri.substring(2 + length.toString(10).length, _uri.length - (2 + seed.toString(16).length + pos.toString(10).length));
|
|
||||||
|
|
||||||
const value = _hash.substring(pos, pos + length);
|
const value = _hash.substring(pos, pos + length);
|
||||||
const hash = _hash.substring(0, pos) + _hash.substring(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);
|
return parseInt(value, 10);
|
||||||
else
|
else
|
||||||
return undefined;
|
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