A new start
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
target/
|
||||||
|
pkg/
|
||||||
|
|
||||||
|
.env.*
|
||||||
|
|
||||||
8
README.md
Normal file
8
README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# pornsuper
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Install node 20.19.1
|
||||||
|
2. `corepack enable`
|
||||||
|
3. `pnpm install`
|
||||||
|
4. `cargo install wasm-bindgen-cli`
|
||||||
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "sexy.pivoine.art",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"build:bundle": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/bundle build",
|
||||||
|
"build:frontend": "git pull && pnpm install && pnpm --filter @sexy.pivoine.art/frontend build",
|
||||||
|
"dev:data": "cd ../compose/data && docker compose up -d",
|
||||||
|
"dev:directus": "cd ../compose/sexy && docker compose --env-file=.env.local up -d directus",
|
||||||
|
"dev": "pnpm dev:data && pnpm dev:directus && pnpm --filter @sexy.pivoine.art/frontend dev"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"packageManager": "pnpm@10.17.0+sha512.fce8a3dd29a4ed2ec566fb53efbb04d8c44a0f05bc6f24a73046910fb9c3ce7afa35a0980500668fa3573345bd644644fa98338fa168235c80f4aa17aa17fbef",
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"svelte-preprocess",
|
||||||
|
"vue-demi"
|
||||||
|
],
|
||||||
|
"ignoredBuiltDependencies": [
|
||||||
|
"@tailwindcss/oxide",
|
||||||
|
"node-sass"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
54
packages/bundle/package.json
Normal file
54
packages/bundle/package.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "@sexy.pivoine.art/bundle",
|
||||||
|
"description": "Please enter a description for your extension",
|
||||||
|
"icon": "extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-extension-bundle"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "bundle",
|
||||||
|
"path": {
|
||||||
|
"app": "dist/app.js",
|
||||||
|
"api": "dist/api.js"
|
||||||
|
},
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "endpoint",
|
||||||
|
"type": "endpoint",
|
||||||
|
"source": "src/endpoint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hook",
|
||||||
|
"type": "hook",
|
||||||
|
"source": "src/hook"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "theme",
|
||||||
|
"type": "theme",
|
||||||
|
"source": "src/theme"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"host": "^11.11.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w --no-minify",
|
||||||
|
"link": "directus-extension link",
|
||||||
|
"validate": "directus-extension validate",
|
||||||
|
"add": "directus-extension add"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "16.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@sindresorhus/slugify": "^3.0.0",
|
||||||
|
"fluent-ffmpeg": "^2.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
61
packages/bundle/src/endpoint/index.ts
Normal file
61
packages/bundle/src/endpoint/index.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
const createPolicyFilter = (policy) => ({
|
||||||
|
_or: [
|
||||||
|
{
|
||||||
|
policies: {
|
||||||
|
policy: {
|
||||||
|
name: {
|
||||||
|
_eq: policy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: {
|
||||||
|
name: {
|
||||||
|
_eq: policy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "sexy",
|
||||||
|
handler: (router, context) => {
|
||||||
|
const { services, getSchema } = context;
|
||||||
|
const { ItemsService } = services;
|
||||||
|
|
||||||
|
router.get("/stats", async (_req, res) => {
|
||||||
|
const usersService = new ItemsService("directus_users", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
});
|
||||||
|
const modelsCount = await usersService.readByQuery({
|
||||||
|
aggregate: {
|
||||||
|
count: ["*"],
|
||||||
|
},
|
||||||
|
filter: createPolicyFilter("Model"),
|
||||||
|
});
|
||||||
|
const viewersCount = await usersService.readByQuery({
|
||||||
|
aggregate: {
|
||||||
|
count: ["*"],
|
||||||
|
},
|
||||||
|
filter: createPolicyFilter("Viewer"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const videosService = new ItemsService("sexy_videos", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
});
|
||||||
|
const videosCount = await videosService.readByQuery({
|
||||||
|
aggregate: {
|
||||||
|
count: ["*"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
models_count: modelsCount[0].count,
|
||||||
|
viewers_count: viewersCount[0].count,
|
||||||
|
videos_count: videosCount[0].count,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
70
packages/bundle/src/hook/index.ts
Normal file
70
packages/bundle/src/hook/index.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { createRequire } from "module";
|
||||||
|
global.require = createRequire(import.meta.url);
|
||||||
|
import { defineHook } from "@directus/extensions-sdk";
|
||||||
|
import slugify from "@sindresorhus/slugify";
|
||||||
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
|
|
||||||
|
async function processVideo(
|
||||||
|
meta,
|
||||||
|
{ schema, accountability },
|
||||||
|
services,
|
||||||
|
logger,
|
||||||
|
) {
|
||||||
|
const { FilesService } = services;
|
||||||
|
const itemId = meta.key;
|
||||||
|
const videoPath = `/directus/uploads/${meta.payload.filename_disk}`; // Adjust path as needed
|
||||||
|
const videoService = new FilesService({ schema, accountability }); // Replace with your collection name
|
||||||
|
|
||||||
|
try {
|
||||||
|
const durationInSeconds = await new Promise((resolve, reject) => {
|
||||||
|
ffmpeg.ffprobe(videoPath, function (err, metadata) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
resolve(parseInt(metadata.format.duration));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Update the item with the duration
|
||||||
|
await videoService.updateOne(itemId, { duration: durationInSeconds });
|
||||||
|
logger.info(`Video ${itemId} duration updated to ${durationInSeconds}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error processing video ${itemId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineHook(async ({ filter, action }, { services, logger }) => {
|
||||||
|
action("files.upload", async (meta, context) => {
|
||||||
|
await processVideo(meta, context, services, logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
filter(
|
||||||
|
"users.create",
|
||||||
|
(payload: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
artist_name: string;
|
||||||
|
slug: string;
|
||||||
|
}) => {
|
||||||
|
const artist_name = `${payload.first_name}-${new Date().getTime()}`;
|
||||||
|
const slug = slugify(artist_name);
|
||||||
|
const join_date = new Date();
|
||||||
|
return { ...payload, artist_name, slug, join_date };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
filter(
|
||||||
|
"users.update",
|
||||||
|
(payload: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
artist_name: string;
|
||||||
|
slug: string;
|
||||||
|
}) => {
|
||||||
|
if (payload.artist_name) {
|
||||||
|
const slug = slugify(payload.artist_name);
|
||||||
|
return { ...payload, slug };
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
130
packages/bundle/src/theme/index.ts
Normal file
130
packages/bundle/src/theme/index.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { defineTheme } from "@directus/extensions-sdk";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
export default defineTheme({
|
||||||
|
id: "@sexy.pivoine.art/theme",
|
||||||
|
name: "Sexy.Art Dark",
|
||||||
|
appearance: "dark",
|
||||||
|
rules: {
|
||||||
|
borderRadius: "6px",
|
||||||
|
borderWidth: "2px",
|
||||||
|
foreground: "#c9d1d9",
|
||||||
|
foregroundSubdued: "#666672",
|
||||||
|
foregroundAccent: "#f0f6fc",
|
||||||
|
background: "#0D1117",
|
||||||
|
backgroundNormal: "#21262E",
|
||||||
|
backgroundAccent: "#30363D",
|
||||||
|
backgroundSubdued: "#161B22",
|
||||||
|
borderColor: "#21262E",
|
||||||
|
borderColorAccent: "#30363D",
|
||||||
|
borderColorSubdued: "#161B22",
|
||||||
|
primary: "#ce47eb",
|
||||||
|
secondary: "#613dff",
|
||||||
|
success: "#87ff66",
|
||||||
|
warning: "#ffbf66",
|
||||||
|
danger: "#ff6467",
|
||||||
|
navigation: {
|
||||||
|
background: "#21262E",
|
||||||
|
backgroundAccent: "#30363D",
|
||||||
|
borderWidth: "0px",
|
||||||
|
borderColor: "transparent",
|
||||||
|
project: {
|
||||||
|
background: "#30363D",
|
||||||
|
borderWidth: "0px",
|
||||||
|
borderColor: "transparent",
|
||||||
|
},
|
||||||
|
modules: {
|
||||||
|
borderWidth: "0px",
|
||||||
|
borderColor: "transparent",
|
||||||
|
button: {
|
||||||
|
foregroundHover: "#fff",
|
||||||
|
background: "transparent",
|
||||||
|
backgroundHover: "transparent",
|
||||||
|
backgroundActive: "#21262E",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
background: "transparent",
|
||||||
|
backgroundHover: "#30363D",
|
||||||
|
backgroundActive: "#30363D",
|
||||||
|
divider: {
|
||||||
|
borderColor: "#30363D",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
borderWidth: "0px",
|
||||||
|
borderColor: "transparent",
|
||||||
|
boxShadow: "0 4px 7px -4px black",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
columnGap: "32px",
|
||||||
|
rowGap: "40px",
|
||||||
|
field: {
|
||||||
|
label: {
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderColor: "#21262E",
|
||||||
|
borderColorHover: "#30363D",
|
||||||
|
boxShadow: "none",
|
||||||
|
boxShadowHover: "none",
|
||||||
|
height: "60px",
|
||||||
|
padding: "16px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
background: "#21262E",
|
||||||
|
borderWidth: "0px",
|
||||||
|
borderColor: "transparent",
|
||||||
|
section: {
|
||||||
|
toggle: {
|
||||||
|
background: "#30363D",
|
||||||
|
borderWidth: "0px",
|
||||||
|
borderColor: "transparent",
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
field: {
|
||||||
|
input: {
|
||||||
|
height: "52px",
|
||||||
|
padding: "12px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
public: {
|
||||||
|
art: {
|
||||||
|
background: "#21262E",
|
||||||
|
speed: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
menu: {
|
||||||
|
background: "#30363D",
|
||||||
|
boxShadow: "0px 0px 6px 0px black",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
background: "#161B22",
|
||||||
|
padding: "40px",
|
||||||
|
avatar: {
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: "50%",
|
||||||
|
},
|
||||||
|
headline: {
|
||||||
|
foreground: "#fff",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
foreground: "#fff",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
foreground: "#969696",
|
||||||
|
},
|
||||||
|
art: {
|
||||||
|
foreground: "#21262E",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
0
packages/bundle/src/theme/style.css
Normal file
0
packages/bundle/src/theme/style.css
Normal file
29
packages/bundle/tsconfig.json
Normal file
29
packages/bundle/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"strictPropertyInitialization": true,
|
||||||
|
"resolveJsonModule": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowJs": true
|
||||||
|
},
|
||||||
|
"include": ["./src/**/*.ts"]
|
||||||
|
}
|
||||||
2
packages/buttplug/.cargo/config.toml
Normal file
2
packages/buttplug/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[target.wasm32-unknown-unknown]
|
||||||
|
rustflags = ['--cfg', 'getrandom_backend="wasm_js"', "--cfg=web_sys_unstable_apis"]
|
||||||
2503
packages/buttplug/Cargo.lock
generated
Normal file
2503
packages/buttplug/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
packages/buttplug/Cargo.toml
Normal file
63
packages/buttplug/Cargo.toml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
[package]
|
||||||
|
name = "buttplug_wasm"
|
||||||
|
version = "9.0.9"
|
||||||
|
authors = ["Nonpolynomial Labs, LLC <kyle@nonpolynomial.com>"]
|
||||||
|
description = "WASM Interop for the Buttplug Intimate Hardware Control Library"
|
||||||
|
license = "BSD-3-Clause"
|
||||||
|
homepage = "http://buttplug.io"
|
||||||
|
repository = "https://github.com/buttplugio/buttplug.git"
|
||||||
|
readme = "./README.md"
|
||||||
|
keywords = ["usb", "serial", "hardware", "bluetooth", "teledildonics"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
name = "buttplug_wasm"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
buttplug = { version = "9.0.9", default-features = false, features = ["wasm"] }
|
||||||
|
# buttplug = { path = "../../../buttplug/buttplug", default-features = false, features = ["wasm"] }
|
||||||
|
# buttplug_derive = { path = "../buttplug_derive" }
|
||||||
|
js-sys = "0.3.80"
|
||||||
|
tracing-wasm = "0.2.1"
|
||||||
|
log-panics = { version = "2.1.0", features = ["with-backtrace"] }
|
||||||
|
console_error_panic_hook = "0.1.7"
|
||||||
|
wasmtimer = "0.4.3"
|
||||||
|
wasm-bindgen = { version = "0.2.103", features = ["serde-serialize"] }
|
||||||
|
tokio = { version = "1.47.1", features = ["sync", "macros", "io-util"] }
|
||||||
|
tokio-stream = "0.1.17"
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-futures = "0.2.5"
|
||||||
|
tracing-subscriber = { version = "0.3.20", features = ["json"] }
|
||||||
|
futures = "0.3.31"
|
||||||
|
futures-util = "0.3.31"
|
||||||
|
async-trait = "0.1.89"
|
||||||
|
wasm-bindgen-futures = "0.4.53"
|
||||||
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
|
parking_lot = { version = "0.11.1", features = ["wasm-bindgen"]}
|
||||||
|
|
||||||
|
[dependencies.web-sys]
|
||||||
|
version = "0.3.80"
|
||||||
|
# path = "../../wasm-bindgen/crates/web-sys"
|
||||||
|
#git = "https://github.com/rustwasm/wasm-bindgen"
|
||||||
|
features = [
|
||||||
|
"Navigator",
|
||||||
|
"Bluetooth",
|
||||||
|
"BluetoothDevice",
|
||||||
|
"BluetoothLeScanFilterInit",
|
||||||
|
"BluetoothRemoteGattCharacteristic",
|
||||||
|
"BluetoothRemoteGattServer",
|
||||||
|
"BluetoothRemoteGattService",
|
||||||
|
"BinaryType",
|
||||||
|
"Blob",
|
||||||
|
"console",
|
||||||
|
"ErrorEvent",
|
||||||
|
"Event",
|
||||||
|
"FileReader",
|
||||||
|
"MessageEvent",
|
||||||
|
"ProgressEvent",
|
||||||
|
"RequestDeviceOptions",
|
||||||
|
"WebSocket",
|
||||||
|
"Window"
|
||||||
|
]
|
||||||
27
packages/buttplug/package.json
Normal file
27
packages/buttplug/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@sexy.pivoine.art/buttplug",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vite": "^7.1.4",
|
||||||
|
"vite-plugin-wasm": "3.5.0",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wasm-pack": "^0.13.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { IButtplugClientConnector } from "./IButtplugClientConnector";
|
||||||
|
import { ButtplugMessage } from "../core/Messages";
|
||||||
|
import { ButtplugBrowserWebsocketConnector } from "../utils/ButtplugBrowserWebsocketConnector";
|
||||||
|
|
||||||
|
export class ButtplugBrowserWebsocketClientConnector
|
||||||
|
extends ButtplugBrowserWebsocketConnector
|
||||||
|
implements IButtplugClientConnector
|
||||||
|
{
|
||||||
|
public send = (msg: ButtplugMessage): void => {
|
||||||
|
if (!this.Connected) {
|
||||||
|
throw new Error("ButtplugClient not connected");
|
||||||
|
}
|
||||||
|
this.sendMessage(msg);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ButtplugError } from "../core/Exceptions";
|
||||||
|
import * as Messages from "../core/Messages";
|
||||||
|
|
||||||
|
export class ButtplugClientConnectorException extends ButtplugError {
|
||||||
|
public constructor(message: string) {
|
||||||
|
super(message, Messages.ErrorClass.ERROR_UNKNOWN);
|
||||||
|
}
|
||||||
|
}
|
||||||
401
packages/buttplug/src/client/ButtplugClientDevice.ts
Normal file
401
packages/buttplug/src/client/ButtplugClientDevice.ts
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
import * as Messages from "../core/Messages";
|
||||||
|
import {
|
||||||
|
ButtplugDeviceError,
|
||||||
|
ButtplugError,
|
||||||
|
ButtplugMessageError,
|
||||||
|
} from "../core/Exceptions";
|
||||||
|
import { EventEmitter } from "eventemitter3";
|
||||||
|
import { getMessageClassFromMessage } from "../core/MessageUtils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an abstract device, capable of taking certain kinds of messages.
|
||||||
|
*/
|
||||||
|
export class ButtplugClientDevice extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* Return the name of the device.
|
||||||
|
*/
|
||||||
|
public get name(): string {
|
||||||
|
return this._deviceInfo.DeviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the user set name of the device.
|
||||||
|
*/
|
||||||
|
public get displayName(): string | undefined {
|
||||||
|
return this._deviceInfo.DeviceDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the index of the device.
|
||||||
|
*/
|
||||||
|
public get index(): number {
|
||||||
|
return this._deviceInfo.DeviceIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the index of the device.
|
||||||
|
*/
|
||||||
|
public get messageTimingGap(): number | undefined {
|
||||||
|
return this._deviceInfo.DeviceMessageTimingGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a list of message types the device accepts.
|
||||||
|
*/
|
||||||
|
public get messageAttributes(): Messages.MessageAttributes {
|
||||||
|
return this._deviceInfo.DeviceMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static fromMsg(
|
||||||
|
msg: Messages.DeviceInfo,
|
||||||
|
sendClosure: (
|
||||||
|
device: ButtplugClientDevice,
|
||||||
|
msg: Messages.ButtplugDeviceMessage,
|
||||||
|
) => Promise<Messages.ButtplugMessage>,
|
||||||
|
): ButtplugClientDevice {
|
||||||
|
return new ButtplugClientDevice(msg, sendClosure);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map of messages and their attributes (feature count, etc...)
|
||||||
|
private allowedMsgs: Map<string, Messages.MessageAttributes> = new Map<
|
||||||
|
string,
|
||||||
|
Messages.MessageAttributes
|
||||||
|
>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param _index Index of the device, as created by the device manager.
|
||||||
|
* @param _name Name of the device.
|
||||||
|
* @param allowedMsgs Buttplug messages the device can receive.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _deviceInfo: Messages.DeviceInfo,
|
||||||
|
private _sendClosure: (
|
||||||
|
device: ButtplugClientDevice,
|
||||||
|
msg: Messages.ButtplugDeviceMessage,
|
||||||
|
) => Promise<Messages.ButtplugMessage>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
_deviceInfo.DeviceMessages.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(
|
||||||
|
msg: Messages.ButtplugDeviceMessage,
|
||||||
|
): Promise<Messages.ButtplugMessage> {
|
||||||
|
// Assume we're getting the closure from ButtplugClient, which does all of
|
||||||
|
// the index/existence/connection/message checks for us.
|
||||||
|
return await this._sendClosure(this, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendExpectOk(
|
||||||
|
msg: Messages.ButtplugDeviceMessage,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await this.send(msg);
|
||||||
|
switch (getMessageClassFromMessage(response)) {
|
||||||
|
case Messages.Ok:
|
||||||
|
return;
|
||||||
|
case Messages.Error:
|
||||||
|
throw ButtplugError.FromError(response as Messages.Error);
|
||||||
|
default:
|
||||||
|
throw new ButtplugMessageError(
|
||||||
|
`Message type ${response.constructor} not handled by SendMsgExpectOk`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async scalar(
|
||||||
|
scalar: Messages.ScalarSubcommand | Messages.ScalarSubcommand[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (Array.isArray(scalar)) {
|
||||||
|
await this.sendExpectOk(new Messages.ScalarCmd(scalar, this.index));
|
||||||
|
} else {
|
||||||
|
await this.sendExpectOk(new Messages.ScalarCmd([scalar], this.index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scalarCommandBuilder(
|
||||||
|
speed: number | number[],
|
||||||
|
actuator: Messages.ActuatorType,
|
||||||
|
) {
|
||||||
|
const scalarAttrs = this.messageAttributes.ScalarCmd?.filter(
|
||||||
|
(x) => x.ActuatorType === actuator,
|
||||||
|
);
|
||||||
|
if (!scalarAttrs || scalarAttrs.length === 0) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no ${actuator} capabilities`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cmds: Messages.ScalarSubcommand[] = [];
|
||||||
|
if (typeof speed === "number") {
|
||||||
|
scalarAttrs.forEach((x) =>
|
||||||
|
cmds.push(new Messages.ScalarSubcommand(x.Index, speed, actuator)),
|
||||||
|
);
|
||||||
|
} else if (Array.isArray(speed)) {
|
||||||
|
if (speed.length > scalarAttrs.length) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`${speed.length} commands send to a device with ${scalarAttrs.length} vibrators`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
scalarAttrs.forEach((x, i) => {
|
||||||
|
cmds.push(new Messages.ScalarSubcommand(x.Index, speed[i], actuator));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`${actuator} can only take numbers or arrays of numbers.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.scalar(cmds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get vibrateAttributes(): Messages.GenericDeviceMessageAttributes[] {
|
||||||
|
return (
|
||||||
|
this.messageAttributes.ScalarCmd?.filter(
|
||||||
|
(x) => x.ActuatorType === Messages.ActuatorType.Vibrate,
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async vibrate(speed: number | number[]): Promise<void> {
|
||||||
|
await this.scalarCommandBuilder(speed, Messages.ActuatorType.Vibrate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get oscillateAttributes(): Messages.GenericDeviceMessageAttributes[] {
|
||||||
|
return (
|
||||||
|
this.messageAttributes.ScalarCmd?.filter(
|
||||||
|
(x) => x.ActuatorType === Messages.ActuatorType.Oscillate,
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async oscillate(speed: number | number[]): Promise<void> {
|
||||||
|
await this.scalarCommandBuilder(speed, Messages.ActuatorType.Oscillate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get rotateAttributes(): Messages.GenericDeviceMessageAttributes[] {
|
||||||
|
return this.messageAttributes.RotateCmd ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rotate(
|
||||||
|
values: number | [number, boolean][],
|
||||||
|
clockwise?: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const rotateAttrs = this.messageAttributes.RotateCmd;
|
||||||
|
if (!rotateAttrs || rotateAttrs.length === 0) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no Rotate capabilities`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let msg: Messages.RotateCmd;
|
||||||
|
if (typeof values === "number") {
|
||||||
|
msg = Messages.RotateCmd.Create(
|
||||||
|
this.index,
|
||||||
|
new Array(rotateAttrs.length).fill([values, clockwise]),
|
||||||
|
);
|
||||||
|
} else if (Array.isArray(values)) {
|
||||||
|
msg = Messages.RotateCmd.Create(this.index, values);
|
||||||
|
} else {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
"SendRotateCmd can only take a number and boolean, or an array of number/boolean tuples",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.sendExpectOk(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get linearAttributes(): Messages.GenericDeviceMessageAttributes[] {
|
||||||
|
return this.messageAttributes.LinearCmd ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async linear(
|
||||||
|
values: number | [number, number][],
|
||||||
|
duration?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const linearAttrs = this.messageAttributes.LinearCmd;
|
||||||
|
if (!linearAttrs || linearAttrs.length === 0) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no Linear capabilities`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let msg: Messages.LinearCmd;
|
||||||
|
if (typeof values === "number") {
|
||||||
|
msg = Messages.LinearCmd.Create(
|
||||||
|
this.index,
|
||||||
|
new Array(linearAttrs.length).fill([values, duration]),
|
||||||
|
);
|
||||||
|
} else if (Array.isArray(values)) {
|
||||||
|
msg = Messages.LinearCmd.Create(this.index, values);
|
||||||
|
} else {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
"SendLinearCmd can only take a number and number, or an array of number/number tuples",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.sendExpectOk(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sensorRead(
|
||||||
|
sensorIndex: number,
|
||||||
|
sensorType: Messages.SensorType,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const response = await this.send(
|
||||||
|
new Messages.SensorReadCmd(this.index, sensorIndex, sensorType),
|
||||||
|
);
|
||||||
|
switch (getMessageClassFromMessage(response)) {
|
||||||
|
case Messages.SensorReading:
|
||||||
|
return (response as Messages.SensorReading).Data;
|
||||||
|
case Messages.Error:
|
||||||
|
throw ButtplugError.FromError(response as Messages.Error);
|
||||||
|
default:
|
||||||
|
throw new ButtplugMessageError(
|
||||||
|
`Message type ${response.constructor} not handled by sensorRead`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get hasBattery(): boolean {
|
||||||
|
const batteryAttrs = this.messageAttributes.SensorReadCmd?.filter(
|
||||||
|
(x) => x.SensorType === Messages.SensorType.Battery,
|
||||||
|
);
|
||||||
|
return batteryAttrs !== undefined && batteryAttrs.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async battery(): Promise<number> {
|
||||||
|
if (!this.hasBattery) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no Battery capabilities`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const batteryAttrs = this.messageAttributes.SensorReadCmd?.filter(
|
||||||
|
(x) => x.SensorType === Messages.SensorType.Battery,
|
||||||
|
);
|
||||||
|
// Find the battery sensor, we'll need its index.
|
||||||
|
const result = await this.sensorRead(
|
||||||
|
batteryAttrs![0].Index,
|
||||||
|
Messages.SensorType.Battery,
|
||||||
|
);
|
||||||
|
return result[0] / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get hasRssi(): boolean {
|
||||||
|
const rssiAttrs = this.messageAttributes.SensorReadCmd?.filter(
|
||||||
|
(x) => x.SensorType === Messages.SensorType.RSSI,
|
||||||
|
);
|
||||||
|
return rssiAttrs !== undefined && rssiAttrs.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rssi(): Promise<number> {
|
||||||
|
if (!this.hasRssi) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no RSSI capabilities`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rssiAttrs = this.messageAttributes.SensorReadCmd?.filter(
|
||||||
|
(x) => x.SensorType === Messages.SensorType.RSSI,
|
||||||
|
);
|
||||||
|
// Find the battery sensor, we'll need its index.
|
||||||
|
const result = await this.sensorRead(
|
||||||
|
rssiAttrs![0].Index,
|
||||||
|
Messages.SensorType.RSSI,
|
||||||
|
);
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rawRead(
|
||||||
|
endpoint: string,
|
||||||
|
expectedLength: number,
|
||||||
|
timeout: number,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
if (!this.messageAttributes.RawReadCmd) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no raw read capabilities`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.messageAttributes.RawReadCmd.Endpoints.indexOf(endpoint) === -1) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no raw readable endpoint ${endpoint}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const response = await this.send(
|
||||||
|
new Messages.RawReadCmd(this.index, endpoint, expectedLength, timeout),
|
||||||
|
);
|
||||||
|
switch (getMessageClassFromMessage(response)) {
|
||||||
|
case Messages.RawReading:
|
||||||
|
return new Uint8Array((response as Messages.RawReading).Data);
|
||||||
|
case Messages.Error:
|
||||||
|
throw ButtplugError.FromError(response as Messages.Error);
|
||||||
|
default:
|
||||||
|
throw new ButtplugMessageError(
|
||||||
|
`Message type ${response.constructor} not handled by rawRead`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rawWrite(
|
||||||
|
endpoint: string,
|
||||||
|
data: Uint8Array,
|
||||||
|
writeWithResponse: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.messageAttributes.RawWriteCmd) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no raw write capabilities`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.messageAttributes.RawWriteCmd.Endpoints.indexOf(endpoint) === -1) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no raw writable endpoint ${endpoint}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.sendExpectOk(
|
||||||
|
new Messages.RawWriteCmd(this.index, endpoint, data, writeWithResponse),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rawSubscribe(endpoint: string): Promise<void> {
|
||||||
|
if (!this.messageAttributes.RawSubscribeCmd) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no raw subscribe capabilities`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.messageAttributes.RawSubscribeCmd.Endpoints.indexOf(endpoint) === -1
|
||||||
|
) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no raw subscribable endpoint ${endpoint}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.sendExpectOk(new Messages.RawSubscribeCmd(this.index, endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rawUnsubscribe(endpoint: string): Promise<void> {
|
||||||
|
// This reuses raw subscribe's info.
|
||||||
|
if (!this.messageAttributes.RawSubscribeCmd) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no raw unsubscribe capabilities`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.messageAttributes.RawSubscribeCmd.Endpoints.indexOf(endpoint) === -1
|
||||||
|
) {
|
||||||
|
throw new ButtplugDeviceError(
|
||||||
|
`Device ${this.name} has no raw unsubscribable endpoint ${endpoint}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.sendExpectOk(
|
||||||
|
new Messages.RawUnsubscribeCmd(this.index, endpoint),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
await this.sendExpectOk(new Messages.StopDeviceCmd(this.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
public emitDisconnected() {
|
||||||
|
this.emit("deviceremoved");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { ButtplugBrowserWebsocketClientConnector } from "./ButtplugBrowserWebsocketClientConnector";
|
||||||
|
import { WebSocket as NodeWebSocket } from "ws";
|
||||||
|
|
||||||
|
export class ButtplugNodeWebsocketClientConnector extends ButtplugBrowserWebsocketClientConnector {
|
||||||
|
protected _websocketConstructor =
|
||||||
|
NodeWebSocket as unknown as typeof WebSocket;
|
||||||
|
}
|
||||||
276
packages/buttplug/src/client/Client.ts
Normal file
276
packages/buttplug/src/client/Client.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { ButtplugLogger } from "../core/Logging";
|
||||||
|
import { EventEmitter } from "eventemitter3";
|
||||||
|
import { ButtplugClientDevice } from "./ButtplugClientDevice";
|
||||||
|
import { IButtplugClientConnector } from "./IButtplugClientConnector";
|
||||||
|
import { ButtplugMessageSorter } from "../utils/ButtplugMessageSorter";
|
||||||
|
|
||||||
|
import * as Messages from "../core/Messages";
|
||||||
|
import {
|
||||||
|
ButtplugDeviceError,
|
||||||
|
ButtplugError,
|
||||||
|
ButtplugInitError,
|
||||||
|
ButtplugMessageError,
|
||||||
|
} from "../core/Exceptions";
|
||||||
|
import { ButtplugClientConnectorException } from "./ButtplugClientConnectorException";
|
||||||
|
import { getMessageClassFromMessage } from "../core/MessageUtils";
|
||||||
|
|
||||||
|
export class ButtplugClient extends EventEmitter {
|
||||||
|
protected _pingTimer: NodeJS.Timeout | null = null;
|
||||||
|
protected _connector: IButtplugClientConnector | null = null;
|
||||||
|
protected _devices: Map<number, ButtplugClientDevice> = new Map();
|
||||||
|
protected _clientName: string;
|
||||||
|
protected _logger = ButtplugLogger.Logger;
|
||||||
|
protected _isScanning = false;
|
||||||
|
private _sorter: ButtplugMessageSorter = new ButtplugMessageSorter(true);
|
||||||
|
|
||||||
|
constructor(clientName = "Generic Buttplug Client") {
|
||||||
|
super();
|
||||||
|
this._clientName = clientName;
|
||||||
|
this._logger.Debug(`ButtplugClient: Client ${clientName} created.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get connected(): boolean {
|
||||||
|
return this._connector !== null && this._connector.Connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get devices(): ButtplugClientDevice[] {
|
||||||
|
// While this function doesn't actually send a message, if we don't have a
|
||||||
|
// connector, we shouldn't have devices.
|
||||||
|
this.checkConnector();
|
||||||
|
const devices: ButtplugClientDevice[] = [];
|
||||||
|
this._devices.forEach((d) => {
|
||||||
|
devices.push(d);
|
||||||
|
});
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isScanning(): boolean {
|
||||||
|
return this._isScanning;
|
||||||
|
}
|
||||||
|
|
||||||
|
public connect = async (connector: IButtplugClientConnector) => {
|
||||||
|
this._logger.Info(
|
||||||
|
`ButtplugClient: Connecting using ${connector.constructor.name}`,
|
||||||
|
);
|
||||||
|
await connector.connect();
|
||||||
|
this._connector = connector;
|
||||||
|
this._connector.addListener("message", this.parseMessages);
|
||||||
|
this._connector.addListener("disconnect", this.disconnectHandler);
|
||||||
|
await this.initializeConnection();
|
||||||
|
};
|
||||||
|
|
||||||
|
public disconnect = async () => {
|
||||||
|
this._logger.Debug("ButtplugClient: Disconnect called");
|
||||||
|
this.checkConnector();
|
||||||
|
await this.shutdownConnection();
|
||||||
|
await this._connector!.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
public startScanning = async () => {
|
||||||
|
this._logger.Debug("ButtplugClient: StartScanning called");
|
||||||
|
this._isScanning = true;
|
||||||
|
await this.sendMsgExpectOk(new Messages.StartScanning());
|
||||||
|
};
|
||||||
|
|
||||||
|
public stopScanning = async () => {
|
||||||
|
this._logger.Debug("ButtplugClient: StopScanning called");
|
||||||
|
this._isScanning = false;
|
||||||
|
await this.sendMsgExpectOk(new Messages.StopScanning());
|
||||||
|
};
|
||||||
|
|
||||||
|
public stopAllDevices = async () => {
|
||||||
|
this._logger.Debug("ButtplugClient: StopAllDevices");
|
||||||
|
await this.sendMsgExpectOk(new Messages.StopAllDevices());
|
||||||
|
};
|
||||||
|
|
||||||
|
private async sendDeviceMessage(
|
||||||
|
device: ButtplugClientDevice,
|
||||||
|
deviceMsg: Messages.ButtplugDeviceMessage,
|
||||||
|
): Promise<Messages.ButtplugMessage> {
|
||||||
|
this.checkConnector();
|
||||||
|
const dev = this._devices.get(device.index);
|
||||||
|
if (dev === undefined) {
|
||||||
|
throw ButtplugError.LogAndError(
|
||||||
|
ButtplugDeviceError,
|
||||||
|
this._logger,
|
||||||
|
`Device ${device.index} not available.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
deviceMsg.DeviceIndex = device.index;
|
||||||
|
return await this.sendMessage(deviceMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected disconnectHandler = () => {
|
||||||
|
this._logger.Info("ButtplugClient: Disconnect event receieved.");
|
||||||
|
this.emit("disconnect");
|
||||||
|
};
|
||||||
|
|
||||||
|
protected parseMessages = (msgs: Messages.ButtplugMessage[]) => {
|
||||||
|
const leftoverMsgs = this._sorter.ParseIncomingMessages(msgs);
|
||||||
|
for (const x of leftoverMsgs) {
|
||||||
|
switch (getMessageClassFromMessage(x)) {
|
||||||
|
case Messages.DeviceAdded: {
|
||||||
|
const addedMsg = x as Messages.DeviceAdded;
|
||||||
|
const addedDevice = ButtplugClientDevice.fromMsg(
|
||||||
|
addedMsg,
|
||||||
|
this.sendDeviceMessageClosure,
|
||||||
|
);
|
||||||
|
this._devices.set(addedMsg.DeviceIndex, addedDevice);
|
||||||
|
this.emit("deviceadded", addedMsg, addedDevice);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Messages.DeviceRemoved: {
|
||||||
|
const removedMsg = x as Messages.DeviceRemoved;
|
||||||
|
if (this._devices.has(removedMsg.DeviceIndex)) {
|
||||||
|
const removedDevice = this._devices.get(removedMsg.DeviceIndex);
|
||||||
|
removedDevice?.emitDisconnected();
|
||||||
|
this._devices.delete(removedMsg.DeviceIndex);
|
||||||
|
this.emit("deviceremoved", removedMsg, removedDevice);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Messages.ScanningFinished:
|
||||||
|
this._isScanning = false;
|
||||||
|
this.emit("scanningfinished", x);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected initializeConnection = async (): Promise<boolean> => {
|
||||||
|
this.checkConnector();
|
||||||
|
const msg = await this.sendMessage(
|
||||||
|
new Messages.RequestServerInfo(
|
||||||
|
this._clientName,
|
||||||
|
Messages.MESSAGE_SPEC_VERSION,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
switch (getMessageClassFromMessage(msg)) {
|
||||||
|
case Messages.ServerInfo: {
|
||||||
|
const serverinfo = msg as Messages.ServerInfo;
|
||||||
|
this._logger.Info(
|
||||||
|
`ButtplugClient: Connected to Server ${serverinfo.ServerName}`,
|
||||||
|
);
|
||||||
|
// TODO: maybe store server name, do something with message template version?
|
||||||
|
const ping = serverinfo.MaxPingTime;
|
||||||
|
if (serverinfo.MessageVersion < Messages.MESSAGE_SPEC_VERSION) {
|
||||||
|
// Disconnect and throw an exception explaining the version mismatch problem.
|
||||||
|
await this._connector!.disconnect();
|
||||||
|
throw ButtplugError.LogAndError(
|
||||||
|
ButtplugInitError,
|
||||||
|
this._logger,
|
||||||
|
`Server protocol version ${serverinfo.MessageVersion} is older than client protocol version ${Messages.MESSAGE_SPEC_VERSION}. Please update server.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ping > 0) {
|
||||||
|
/*
|
||||||
|
this._pingTimer = setInterval(async () => {
|
||||||
|
// If we've disconnected, stop trying to ping the server.
|
||||||
|
if (!this.Connected) {
|
||||||
|
await this.ShutdownConnection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.SendMessage(new Messages.Ping());
|
||||||
|
} , Math.round(ping / 2));
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
await this.requestDeviceList();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case Messages.Error: {
|
||||||
|
// Disconnect and throw an exception with the error message we got back.
|
||||||
|
// This will usually only error out if we have a version mismatch that the
|
||||||
|
// server has detected.
|
||||||
|
await this._connector!.disconnect();
|
||||||
|
const err = msg as Messages.Error;
|
||||||
|
throw ButtplugError.LogAndError(
|
||||||
|
ButtplugInitError,
|
||||||
|
this._logger,
|
||||||
|
`Cannot connect to server. ${err.ErrorMessage}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
protected requestDeviceList = async () => {
|
||||||
|
this.checkConnector();
|
||||||
|
this._logger.Debug("ButtplugClient: ReceiveDeviceList called");
|
||||||
|
const deviceList = (await this.sendMessage(
|
||||||
|
new Messages.RequestDeviceList(),
|
||||||
|
)) as Messages.DeviceList;
|
||||||
|
deviceList.Devices.forEach((d) => {
|
||||||
|
if (!this._devices.has(d.DeviceIndex)) {
|
||||||
|
const device = ButtplugClientDevice.fromMsg(
|
||||||
|
d,
|
||||||
|
this.sendDeviceMessageClosure,
|
||||||
|
);
|
||||||
|
this._logger.Debug(`ButtplugClient: Adding Device: ${device}`);
|
||||||
|
this._devices.set(d.DeviceIndex, device);
|
||||||
|
this.emit("deviceadded", device);
|
||||||
|
} else {
|
||||||
|
this._logger.Debug(`ButtplugClient: Device already added: ${d}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
protected shutdownConnection = async () => {
|
||||||
|
await this.stopAllDevices();
|
||||||
|
if (this._pingTimer !== null) {
|
||||||
|
clearInterval(this._pingTimer);
|
||||||
|
this._pingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected async sendMessage(
|
||||||
|
msg: Messages.ButtplugMessage,
|
||||||
|
): Promise<Messages.ButtplugMessage> {
|
||||||
|
this.checkConnector();
|
||||||
|
const p = this._sorter.PrepareOutgoingMessage(msg);
|
||||||
|
await this._connector!.send(msg);
|
||||||
|
return await p;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected checkConnector() {
|
||||||
|
if (!this.connected) {
|
||||||
|
throw new ButtplugClientConnectorException(
|
||||||
|
"ButtplugClient not connected",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sendMsgExpectOk = async (
|
||||||
|
msg: Messages.ButtplugMessage,
|
||||||
|
): Promise<void> => {
|
||||||
|
const response = await this.sendMessage(msg);
|
||||||
|
switch (getMessageClassFromMessage(response)) {
|
||||||
|
case Messages.Ok:
|
||||||
|
return;
|
||||||
|
case Messages.Error:
|
||||||
|
throw ButtplugError.FromError(response as Messages.Error);
|
||||||
|
default:
|
||||||
|
throw ButtplugError.LogAndError(
|
||||||
|
ButtplugMessageError,
|
||||||
|
this._logger,
|
||||||
|
`Message type ${getMessageClassFromMessage(response)!.constructor} not handled by SendMsgExpectOk`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected sendDeviceMessageClosure = async (
|
||||||
|
device: ButtplugClientDevice,
|
||||||
|
msg: Messages.ButtplugDeviceMessage,
|
||||||
|
): Promise<Messages.ButtplugMessage> => {
|
||||||
|
return await this.sendDeviceMessage(device, msg);
|
||||||
|
};
|
||||||
|
}
|
||||||
18
packages/buttplug/src/client/IButtplugClientConnector.ts
Normal file
18
packages/buttplug/src/client/IButtplugClientConnector.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ButtplugMessage } from "../core/Messages";
|
||||||
|
import { EventEmitter } from "eventemitter3";
|
||||||
|
|
||||||
|
export interface IButtplugClientConnector extends EventEmitter {
|
||||||
|
connect: () => Promise<void>;
|
||||||
|
disconnect: () => Promise<void>;
|
||||||
|
initialize: () => Promise<void>;
|
||||||
|
send: (msg: ButtplugMessage) => void;
|
||||||
|
readonly Connected: boolean;
|
||||||
|
}
|
||||||
101
packages/buttplug/src/core/Exceptions.ts
Normal file
101
packages/buttplug/src/core/Exceptions.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Messages from "./Messages";
|
||||||
|
import { ButtplugLogger } from "./Logging";
|
||||||
|
|
||||||
|
export class ButtplugError extends Error {
|
||||||
|
public get ErrorClass(): Messages.ErrorClass {
|
||||||
|
return this.errorClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get InnerError(): Error | undefined {
|
||||||
|
return this.innerError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get Id(): number | undefined {
|
||||||
|
return this.messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get ErrorMessage(): Messages.ButtplugMessage {
|
||||||
|
return new Messages.Error(this.message, this.ErrorClass, this.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LogAndError<T extends ButtplugError>(
|
||||||
|
constructor: new (str: string, num: number) => T,
|
||||||
|
logger: ButtplugLogger,
|
||||||
|
message: string,
|
||||||
|
id: number = Messages.SYSTEM_MESSAGE_ID,
|
||||||
|
): T {
|
||||||
|
logger.Error(message);
|
||||||
|
return new constructor(message, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FromError(error: Messages.Error) {
|
||||||
|
switch (error.ErrorCode) {
|
||||||
|
case Messages.ErrorClass.ERROR_DEVICE:
|
||||||
|
return new ButtplugDeviceError(error.ErrorMessage, error.Id);
|
||||||
|
case Messages.ErrorClass.ERROR_INIT:
|
||||||
|
return new ButtplugInitError(error.ErrorMessage, error.Id);
|
||||||
|
case Messages.ErrorClass.ERROR_UNKNOWN:
|
||||||
|
return new ButtplugUnknownError(error.ErrorMessage, error.Id);
|
||||||
|
case Messages.ErrorClass.ERROR_PING:
|
||||||
|
return new ButtplugPingError(error.ErrorMessage, error.Id);
|
||||||
|
case Messages.ErrorClass.ERROR_MSG:
|
||||||
|
return new ButtplugMessageError(error.ErrorMessage, error.Id);
|
||||||
|
default:
|
||||||
|
throw new Error(`Message type ${error.ErrorCode} not handled`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public errorClass: Messages.ErrorClass = Messages.ErrorClass.ERROR_UNKNOWN;
|
||||||
|
public innerError: Error | undefined;
|
||||||
|
public messageId: number | undefined;
|
||||||
|
|
||||||
|
protected constructor(
|
||||||
|
message: string,
|
||||||
|
errorClass: Messages.ErrorClass,
|
||||||
|
id: number = Messages.SYSTEM_MESSAGE_ID,
|
||||||
|
inner?: Error,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.errorClass = errorClass;
|
||||||
|
this.innerError = inner;
|
||||||
|
this.messageId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ButtplugInitError extends ButtplugError {
|
||||||
|
public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) {
|
||||||
|
super(message, Messages.ErrorClass.ERROR_INIT, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ButtplugDeviceError extends ButtplugError {
|
||||||
|
public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) {
|
||||||
|
super(message, Messages.ErrorClass.ERROR_DEVICE, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ButtplugMessageError extends ButtplugError {
|
||||||
|
public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) {
|
||||||
|
super(message, Messages.ErrorClass.ERROR_MSG, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ButtplugPingError extends ButtplugError {
|
||||||
|
public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) {
|
||||||
|
super(message, Messages.ErrorClass.ERROR_PING, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ButtplugUnknownError extends ButtplugError {
|
||||||
|
public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) {
|
||||||
|
super(message, Messages.ErrorClass.ERROR_UNKNOWN, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
195
packages/buttplug/src/core/Logging.ts
Normal file
195
packages/buttplug/src/core/Logging.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from "eventemitter3";
|
||||||
|
|
||||||
|
export enum ButtplugLogLevel {
|
||||||
|
Off,
|
||||||
|
Error,
|
||||||
|
Warn,
|
||||||
|
Info,
|
||||||
|
Debug,
|
||||||
|
Trace,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Representation of log messages for the internal logging utility.
|
||||||
|
*/
|
||||||
|
export class LogMessage {
|
||||||
|
/** Timestamp for the log message */
|
||||||
|
private timestamp: string;
|
||||||
|
|
||||||
|
/** Log Message */
|
||||||
|
private logMessage: string;
|
||||||
|
|
||||||
|
/** Log Level */
|
||||||
|
private logLevel: ButtplugLogLevel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param logMessage Log message.
|
||||||
|
* @param logLevel: Log severity level.
|
||||||
|
*/
|
||||||
|
public constructor(logMessage: string, logLevel: ButtplugLogLevel) {
|
||||||
|
const a = new Date();
|
||||||
|
const hour = a.getHours();
|
||||||
|
const min = a.getMinutes();
|
||||||
|
const sec = a.getSeconds();
|
||||||
|
this.timestamp = `${hour}:${min}:${sec}`;
|
||||||
|
this.logMessage = logMessage;
|
||||||
|
this.logLevel = logLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the log message.
|
||||||
|
*/
|
||||||
|
public get Message() {
|
||||||
|
return this.logMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the log message level.
|
||||||
|
*/
|
||||||
|
public get LogLevel() {
|
||||||
|
return this.logLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the log message timestamp.
|
||||||
|
*/
|
||||||
|
public get Timestamp() {
|
||||||
|
return this.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a formatted string with timestamp, level, and message.
|
||||||
|
*/
|
||||||
|
public get FormattedMessage() {
|
||||||
|
return `${ButtplugLogLevel[this.logLevel]} : ${this.timestamp} : ${this.logMessage}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple, global logging utility for the Buttplug client and server. Keeps an
|
||||||
|
* internal static reference to an instance of itself (singleton pattern,
|
||||||
|
* basically), and allows message logging throughout the module.
|
||||||
|
*/
|
||||||
|
export class ButtplugLogger extends EventEmitter {
|
||||||
|
/** Singleton instance for the logger */
|
||||||
|
protected static sLogger: ButtplugLogger | undefined = undefined;
|
||||||
|
/** Sets maximum log level to log to console */
|
||||||
|
protected maximumConsoleLogLevel: ButtplugLogLevel = ButtplugLogLevel.Off;
|
||||||
|
/** Sets maximum log level for all log messages */
|
||||||
|
protected maximumEventLogLevel: ButtplugLogLevel = ButtplugLogLevel.Off;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored static instance of the logger, creating one if it
|
||||||
|
* doesn't currently exist.
|
||||||
|
*/
|
||||||
|
public static get Logger(): ButtplugLogger {
|
||||||
|
if (ButtplugLogger.sLogger === undefined) {
|
||||||
|
ButtplugLogger.sLogger = new ButtplugLogger();
|
||||||
|
}
|
||||||
|
return this.sLogger!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor. Can only be called internally since we regulate ButtplugLogger
|
||||||
|
* ownership.
|
||||||
|
*/
|
||||||
|
protected constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the maximum log level to output to console.
|
||||||
|
*/
|
||||||
|
public get MaximumConsoleLogLevel() {
|
||||||
|
return this.maximumConsoleLogLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the maximum log level to output to console.
|
||||||
|
*/
|
||||||
|
public set MaximumConsoleLogLevel(buttplugLogLevel: ButtplugLogLevel) {
|
||||||
|
this.maximumConsoleLogLevel = buttplugLogLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the global maximum log level
|
||||||
|
*/
|
||||||
|
public get MaximumEventLogLevel() {
|
||||||
|
return this.maximumEventLogLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the global maximum log level
|
||||||
|
*/
|
||||||
|
public set MaximumEventLogLevel(logLevel: ButtplugLogLevel) {
|
||||||
|
this.maximumEventLogLevel = logLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log new message at Error level.
|
||||||
|
*/
|
||||||
|
public Error(msg: string) {
|
||||||
|
this.AddLogMessage(msg, ButtplugLogLevel.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log new message at Warn level.
|
||||||
|
*/
|
||||||
|
public Warn(msg: string) {
|
||||||
|
this.AddLogMessage(msg, ButtplugLogLevel.Warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log new message at Info level.
|
||||||
|
*/
|
||||||
|
public Info(msg: string) {
|
||||||
|
this.AddLogMessage(msg, ButtplugLogLevel.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log new message at Debug level.
|
||||||
|
*/
|
||||||
|
public Debug(msg: string) {
|
||||||
|
this.AddLogMessage(msg, ButtplugLogLevel.Debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log new message at Trace level.
|
||||||
|
*/
|
||||||
|
public Trace(msg: string) {
|
||||||
|
this.AddLogMessage(msg, ButtplugLogLevel.Trace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks to see if message should be logged, and if so, adds message to the
|
||||||
|
* log buffer. May also print message and emit event.
|
||||||
|
*/
|
||||||
|
protected AddLogMessage(msg: string, level: ButtplugLogLevel) {
|
||||||
|
// If nothing wants the log message we have, ignore it.
|
||||||
|
if (
|
||||||
|
level > this.maximumEventLogLevel &&
|
||||||
|
level > this.maximumConsoleLogLevel
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const logMsg = new LogMessage(msg, level);
|
||||||
|
// Clients and console logging may have different needs. For instance, it
|
||||||
|
// could be that the client requests trace level, while all we want in the
|
||||||
|
// console is info level. This makes sure the client can't also spam the
|
||||||
|
// console.
|
||||||
|
if (level <= this.maximumConsoleLogLevel) {
|
||||||
|
console.log(logMsg.FormattedMessage);
|
||||||
|
}
|
||||||
|
if (level <= this.maximumEventLogLevel) {
|
||||||
|
this.emit("log", logMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
packages/buttplug/src/core/MessageUtils.ts
Normal file
48
packages/buttplug/src/core/MessageUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
import * as Messages from "./Messages";
|
||||||
|
|
||||||
|
function getMessageClass(
|
||||||
|
type: string,
|
||||||
|
): (new (...args: unknown[]) => Messages.ButtplugMessage) | null {
|
||||||
|
for (const value of Object.values(Messages)) {
|
||||||
|
if (typeof value === "function" && "Name" in value && value.Name === type) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageClassFromMessage(
|
||||||
|
msg: Messages.ButtplugMessage,
|
||||||
|
): (new (...args: unknown[]) => Messages.ButtplugMessage) | null {
|
||||||
|
// Making the bold assumption all message classes have the Name static. Should define a
|
||||||
|
// requirement for this in the abstract class.
|
||||||
|
return getMessageClass(Object.getPrototypeOf(msg).constructor.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromJSON(str): Messages.ButtplugMessage[] {
|
||||||
|
const msgarray: object[] = JSON.parse(str);
|
||||||
|
const msgs: Messages.ButtplugMessage[] = [];
|
||||||
|
for (const x of Array.from(msgarray)) {
|
||||||
|
const type = Object.getOwnPropertyNames(x)[0];
|
||||||
|
const cls = getMessageClass(type);
|
||||||
|
if (cls) {
|
||||||
|
const msg = plainToInstance<Messages.ButtplugMessage, unknown>(
|
||||||
|
cls,
|
||||||
|
x[type],
|
||||||
|
);
|
||||||
|
msg.update();
|
||||||
|
msgs.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msgs;
|
||||||
|
}
|
||||||
491
packages/buttplug/src/core/Messages.ts
Normal file
491
packages/buttplug/src/core/Messages.ts
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// tslint:disable:max-classes-per-file
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { instanceToPlain, Type } from "class-transformer";
|
||||||
|
import "reflect-metadata";
|
||||||
|
|
||||||
|
export const SYSTEM_MESSAGE_ID = 0;
|
||||||
|
export const DEFAULT_MESSAGE_ID = 1;
|
||||||
|
export const MAX_ID = 4294967295;
|
||||||
|
export const MESSAGE_SPEC_VERSION = 3;
|
||||||
|
|
||||||
|
export class MessageAttributes {
|
||||||
|
public ScalarCmd?: Array<GenericDeviceMessageAttributes>;
|
||||||
|
public RotateCmd?: Array<GenericDeviceMessageAttributes>;
|
||||||
|
public LinearCmd?: Array<GenericDeviceMessageAttributes>;
|
||||||
|
public RawReadCmd?: RawDeviceMessageAttributes;
|
||||||
|
public RawWriteCmd?: RawDeviceMessageAttributes;
|
||||||
|
public RawSubscribeCmd?: RawDeviceMessageAttributes;
|
||||||
|
public SensorReadCmd?: Array<SensorDeviceMessageAttributes>;
|
||||||
|
public SensorSubscribeCmd?: Array<SensorDeviceMessageAttributes>;
|
||||||
|
public StopDeviceCmd: {};
|
||||||
|
|
||||||
|
constructor(data: Partial<MessageAttributes>) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update() {
|
||||||
|
this.ScalarCmd?.forEach((x, i) => (x.Index = i));
|
||||||
|
this.RotateCmd?.forEach((x, i) => (x.Index = i));
|
||||||
|
this.LinearCmd?.forEach((x, i) => (x.Index = i));
|
||||||
|
this.SensorReadCmd?.forEach((x, i) => (x.Index = i));
|
||||||
|
this.SensorSubscribeCmd?.forEach((x, i) => (x.Index = i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ActuatorType {
|
||||||
|
Unknown = "Unknown",
|
||||||
|
Vibrate = "Vibrate",
|
||||||
|
Rotate = "Rotate",
|
||||||
|
Oscillate = "Oscillate",
|
||||||
|
Constrict = "Constrict",
|
||||||
|
Inflate = "Inflate",
|
||||||
|
Position = "Position",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SensorType {
|
||||||
|
Unknown = "Unknown",
|
||||||
|
Battery = "Battery",
|
||||||
|
RSSI = "RSSI",
|
||||||
|
Button = "Button",
|
||||||
|
Pressure = "Pressure",
|
||||||
|
// Temperature,
|
||||||
|
// Accelerometer,
|
||||||
|
// Gyro,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenericDeviceMessageAttributes {
|
||||||
|
public FeatureDescriptor: string;
|
||||||
|
public ActuatorType: ActuatorType;
|
||||||
|
public StepCount: number;
|
||||||
|
public Index = 0;
|
||||||
|
constructor(data: Partial<GenericDeviceMessageAttributes>) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RawDeviceMessageAttributes {
|
||||||
|
constructor(public Endpoints: Array<string>) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SensorDeviceMessageAttributes {
|
||||||
|
public FeatureDescriptor: string;
|
||||||
|
public SensorType: SensorType;
|
||||||
|
public StepRange: Array<number>;
|
||||||
|
public Index = 0;
|
||||||
|
constructor(data: Partial<GenericDeviceMessageAttributes>) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class ButtplugMessage {
|
||||||
|
constructor(public Id: number) {}
|
||||||
|
|
||||||
|
// tslint:disable-next-line:ban-types
|
||||||
|
public get Type(): Function {
|
||||||
|
return this.constructor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public toJSON(): string {
|
||||||
|
return JSON.stringify(this.toProtocolFormat());
|
||||||
|
}
|
||||||
|
|
||||||
|
public toProtocolFormat(): object {
|
||||||
|
const jsonObj = {};
|
||||||
|
jsonObj[(this.constructor as unknown as { Name: string }).Name] =
|
||||||
|
instanceToPlain(this);
|
||||||
|
return jsonObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
public update() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class ButtplugDeviceMessage extends ButtplugMessage {
|
||||||
|
constructor(
|
||||||
|
public DeviceIndex: number,
|
||||||
|
public Id: number,
|
||||||
|
) {
|
||||||
|
super(Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class ButtplugSystemMessage extends ButtplugMessage {
|
||||||
|
constructor(public Id: number = SYSTEM_MESSAGE_ID) {
|
||||||
|
super(Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Ok extends ButtplugSystemMessage {
|
||||||
|
static Name = "Ok";
|
||||||
|
|
||||||
|
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
|
||||||
|
super(Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Ping extends ButtplugMessage {
|
||||||
|
static Name = "Ping";
|
||||||
|
|
||||||
|
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
|
||||||
|
super(Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ErrorClass {
|
||||||
|
ERROR_UNKNOWN,
|
||||||
|
ERROR_INIT,
|
||||||
|
ERROR_PING,
|
||||||
|
ERROR_MSG,
|
||||||
|
ERROR_DEVICE,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Error extends ButtplugMessage {
|
||||||
|
static Name = "Error";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public ErrorMessage: string,
|
||||||
|
public ErrorCode: ErrorClass = ErrorClass.ERROR_UNKNOWN,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get Schemversion() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceInfo {
|
||||||
|
public DeviceIndex: number;
|
||||||
|
public DeviceName: string;
|
||||||
|
@Type(() => MessageAttributes)
|
||||||
|
public DeviceMessages: MessageAttributes;
|
||||||
|
public DeviceDisplayName?: string;
|
||||||
|
public DeviceMessageTimingGap?: number;
|
||||||
|
|
||||||
|
constructor(data: Partial<DeviceInfo>) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceList extends ButtplugMessage {
|
||||||
|
static Name = "DeviceList";
|
||||||
|
|
||||||
|
@Type(() => DeviceInfo)
|
||||||
|
public Devices: DeviceInfo[];
|
||||||
|
public Id: number;
|
||||||
|
|
||||||
|
constructor(devices: DeviceInfo[], id: number = DEFAULT_MESSAGE_ID) {
|
||||||
|
super(id);
|
||||||
|
this.Devices = devices;
|
||||||
|
this.Id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public update() {
|
||||||
|
for (const device of this.Devices) {
|
||||||
|
device.DeviceMessages.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceAdded extends ButtplugSystemMessage {
|
||||||
|
static Name = "DeviceAdded";
|
||||||
|
|
||||||
|
public DeviceIndex: number;
|
||||||
|
public DeviceName: string;
|
||||||
|
@Type(() => MessageAttributes)
|
||||||
|
public DeviceMessages: MessageAttributes;
|
||||||
|
public DeviceDisplayName?: string;
|
||||||
|
public DeviceMessageTimingGap?: number;
|
||||||
|
|
||||||
|
constructor(data: Partial<DeviceAdded>) {
|
||||||
|
super();
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public update() {
|
||||||
|
this.DeviceMessages.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DeviceRemoved extends ButtplugSystemMessage {
|
||||||
|
static Name = "DeviceRemoved";
|
||||||
|
|
||||||
|
constructor(public DeviceIndex: number) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RequestDeviceList extends ButtplugMessage {
|
||||||
|
static Name = "RequestDeviceList";
|
||||||
|
|
||||||
|
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
|
||||||
|
super(Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StartScanning extends ButtplugMessage {
|
||||||
|
static Name = "StartScanning";
|
||||||
|
|
||||||
|
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
|
||||||
|
super(Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StopScanning extends ButtplugMessage {
|
||||||
|
static Name = "StopScanning";
|
||||||
|
|
||||||
|
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
|
||||||
|
super(Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScanningFinished extends ButtplugSystemMessage {
|
||||||
|
static Name = "ScanningFinished";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RequestServerInfo extends ButtplugMessage {
|
||||||
|
static Name = "RequestServerInfo";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public ClientName: string,
|
||||||
|
public MessageVersion: number = 0,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerInfo extends ButtplugSystemMessage {
|
||||||
|
static Name = "ServerInfo";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public MessageVersion: number,
|
||||||
|
public MaxPingTime: number,
|
||||||
|
public ServerName: string,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StopDeviceCmd extends ButtplugDeviceMessage {
|
||||||
|
static Name = "StopDeviceCmd";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public DeviceIndex: number = -1,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(DeviceIndex, Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StopAllDevices extends ButtplugMessage {
|
||||||
|
static Name = "StopAllDevices";
|
||||||
|
|
||||||
|
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
|
||||||
|
super(Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenericMessageSubcommand {
|
||||||
|
protected constructor(public Index: number) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScalarSubcommand extends GenericMessageSubcommand {
|
||||||
|
constructor(
|
||||||
|
Index: number,
|
||||||
|
public Scalar: number,
|
||||||
|
public ActuatorType: ActuatorType,
|
||||||
|
) {
|
||||||
|
super(Index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScalarCmd extends ButtplugDeviceMessage {
|
||||||
|
static Name = "ScalarCmd";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public Scalars: ScalarSubcommand[],
|
||||||
|
public DeviceIndex: number = -1,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(DeviceIndex, Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RotateSubcommand extends GenericMessageSubcommand {
|
||||||
|
constructor(
|
||||||
|
Index: number,
|
||||||
|
public Speed: number,
|
||||||
|
public Clockwise: boolean,
|
||||||
|
) {
|
||||||
|
super(Index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RotateCmd extends ButtplugDeviceMessage {
|
||||||
|
static Name = "RotateCmd";
|
||||||
|
|
||||||
|
public static Create(
|
||||||
|
deviceIndex: number,
|
||||||
|
commands: [number, boolean][],
|
||||||
|
): RotateCmd {
|
||||||
|
const cmdList: RotateSubcommand[] = new Array<RotateSubcommand>();
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
for (const [speed, clockwise] of commands) {
|
||||||
|
cmdList.push(new RotateSubcommand(i, speed, clockwise));
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RotateCmd(cmdList, deviceIndex);
|
||||||
|
}
|
||||||
|
constructor(
|
||||||
|
public Rotations: RotateSubcommand[],
|
||||||
|
public DeviceIndex: number = -1,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(DeviceIndex, Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VectorSubcommand extends GenericMessageSubcommand {
|
||||||
|
constructor(
|
||||||
|
Index: number,
|
||||||
|
public Position: number,
|
||||||
|
public Duration: number,
|
||||||
|
) {
|
||||||
|
super(Index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LinearCmd extends ButtplugDeviceMessage {
|
||||||
|
static Name = "LinearCmd";
|
||||||
|
|
||||||
|
public static Create(
|
||||||
|
deviceIndex: number,
|
||||||
|
commands: [number, number][],
|
||||||
|
): LinearCmd {
|
||||||
|
const cmdList: VectorSubcommand[] = new Array<VectorSubcommand>();
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
for (const cmd of commands) {
|
||||||
|
cmdList.push(new VectorSubcommand(i, cmd[0], cmd[1]));
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LinearCmd(cmdList, deviceIndex);
|
||||||
|
}
|
||||||
|
constructor(
|
||||||
|
public Vectors: VectorSubcommand[],
|
||||||
|
public DeviceIndex: number = -1,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(DeviceIndex, Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SensorReadCmd extends ButtplugDeviceMessage {
|
||||||
|
static Name = "SensorReadCmd";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public DeviceIndex: number,
|
||||||
|
public SensorIndex: number,
|
||||||
|
public SensorType: SensorType,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(DeviceIndex, Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SensorReading extends ButtplugDeviceMessage {
|
||||||
|
static Name = "SensorReading";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public DeviceIndex: number,
|
||||||
|
public SensorIndex: number,
|
||||||
|
public SensorType: SensorType,
|
||||||
|
public Data: number[],
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(DeviceIndex, Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RawReadCmd extends ButtplugDeviceMessage {
|
||||||
|
static Name = "RawReadCmd";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public DeviceIndex: number,
|
||||||
|
public Endpoint: string,
|
||||||
|
public ExpectedLength: number,
|
||||||
|
public Timeout: number,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(DeviceIndex, Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RawWriteCmd extends ButtplugDeviceMessage {
|
||||||
|
static Name = "RawWriteCmd";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public DeviceIndex: number,
|
||||||
|
public Endpoint: string,
|
||||||
|
public Data: Uint8Array,
|
||||||
|
public WriteWithResponse: boolean,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(DeviceIndex, Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RawSubscribeCmd extends ButtplugDeviceMessage {
|
||||||
|
static Name = "RawSubscribeCmd";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public DeviceIndex: number,
|
||||||
|
public Endpoint: string,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(DeviceIndex, Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RawUnsubscribeCmd extends ButtplugDeviceMessage {
|
||||||
|
static Name = "RawUnsubscribeCmd";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public DeviceIndex: number,
|
||||||
|
public Endpoint: string,
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(DeviceIndex, Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RawReading extends ButtplugDeviceMessage {
|
||||||
|
static Name = "RawReading";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public DeviceIndex: number,
|
||||||
|
public Endpoint: string,
|
||||||
|
public Data: number[],
|
||||||
|
public Id: number = DEFAULT_MESSAGE_ID,
|
||||||
|
) {
|
||||||
|
super(DeviceIndex, Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
packages/buttplug/src/core/index.d.ts
vendored
Normal file
4
packages/buttplug/src/core/index.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*.json" {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
88
packages/buttplug/src/index.ts
Normal file
88
packages/buttplug/src/index.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { ButtplugMessage } from "./core/Messages";
|
||||||
|
import { IButtplugClientConnector } from "./client/IButtplugClientConnector";
|
||||||
|
import { fromJSON } from "./core/MessageUtils";
|
||||||
|
import { EventEmitter } from "eventemitter3";
|
||||||
|
|
||||||
|
export * from "./client/Client";
|
||||||
|
export * from "./client/ButtplugClientDevice";
|
||||||
|
export * from "./client/ButtplugBrowserWebsocketClientConnector";
|
||||||
|
export * from "./client/ButtplugNodeWebsocketClientConnector";
|
||||||
|
export * from "./client/ButtplugClientConnectorException";
|
||||||
|
export * from "./utils/ButtplugMessageSorter";
|
||||||
|
export * from "./client/IButtplugClientConnector";
|
||||||
|
export * from "./core/Messages";
|
||||||
|
export * from "./core/MessageUtils";
|
||||||
|
export * from "./core/Logging";
|
||||||
|
export * from "./core/Exceptions";
|
||||||
|
|
||||||
|
export class ButtplugWasmClientConnector
|
||||||
|
extends EventEmitter
|
||||||
|
implements IButtplugClientConnector
|
||||||
|
{
|
||||||
|
private static _loggingActivated = false;
|
||||||
|
private static wasmInstance;
|
||||||
|
private _connected: boolean = false;
|
||||||
|
private client;
|
||||||
|
private serverPtr;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get Connected(): boolean {
|
||||||
|
return this._connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static maybeLoadWasm = async () => {
|
||||||
|
if (ButtplugWasmClientConnector.wasmInstance == undefined) {
|
||||||
|
ButtplugWasmClientConnector.wasmInstance = await import(
|
||||||
|
"../wasm/index.js"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static activateLogging = async (logLevel: string = "debug") => {
|
||||||
|
await ButtplugWasmClientConnector.maybeLoadWasm();
|
||||||
|
if (this._loggingActivated) {
|
||||||
|
console.log("Logging already activated, ignoring.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("Turning on logging.");
|
||||||
|
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(
|
||||||
|
logLevel,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
public initialize = async (): Promise<void> => {};
|
||||||
|
|
||||||
|
public connect = async (): Promise<void> => {
|
||||||
|
await ButtplugWasmClientConnector.maybeLoadWasm();
|
||||||
|
//ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger('debug');
|
||||||
|
this.client =
|
||||||
|
ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
|
||||||
|
(msgs) => {
|
||||||
|
this.emitMessage(msgs);
|
||||||
|
},
|
||||||
|
this.serverPtr,
|
||||||
|
);
|
||||||
|
this._connected = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
public disconnect = async (): Promise<void> => {};
|
||||||
|
|
||||||
|
public send = (msg: ButtplugMessage): void => {
|
||||||
|
ButtplugWasmClientConnector.wasmInstance.buttplug_client_send_json_message(
|
||||||
|
this.client,
|
||||||
|
new TextEncoder().encode("[" + msg.toJSON() + "]"),
|
||||||
|
(output) => {
|
||||||
|
this.emitMessage(output);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private emitMessage = (msg: Uint8Array) => {
|
||||||
|
const str = new TextDecoder().decode(msg);
|
||||||
|
// This needs to use buttplug-js's fromJSON, otherwise we won't resolve the message name correctly.
|
||||||
|
this.emit("message", fromJSON(str));
|
||||||
|
};
|
||||||
|
}
|
||||||
130
packages/buttplug/src/lib.rs
Normal file
130
packages/buttplug/src/lib.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#[macro_use]
|
||||||
|
extern crate tracing;
|
||||||
|
#[macro_use]
|
||||||
|
extern crate futures;
|
||||||
|
|
||||||
|
|
||||||
|
mod webbluetooth;
|
||||||
|
use js_sys;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
use crate::webbluetooth::{WebBluetoothCommunicationManagerBuilder};
|
||||||
|
use buttplug::{
|
||||||
|
core::message::{ButtplugServerMessageCurrent,serializer::vec_to_protocol_json},
|
||||||
|
server::{ButtplugServerBuilder,ButtplugServerDowngradeWrapper,device::{ServerDeviceManagerBuilder,configuration::{DeviceConfigurationManager}}},
|
||||||
|
util::async_manager, core::message::{BUTTPLUG_CURRENT_MESSAGE_SPEC_VERSION, ButtplugServerMessageVariant, serializer::{ButtplugSerializedMessage, ButtplugMessageSerializer, ButtplugServerJSONSerializer}},
|
||||||
|
util::device_configuration::load_protocol_configs
|
||||||
|
};
|
||||||
|
|
||||||
|
type FFICallback = js_sys::Function;
|
||||||
|
type FFICallbackContext = u32;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct FFICallbackContextWrapper(FFICallbackContext);
|
||||||
|
|
||||||
|
unsafe impl Send for FFICallbackContextWrapper {
|
||||||
|
}
|
||||||
|
unsafe impl Sync for FFICallbackContextWrapper {
|
||||||
|
}
|
||||||
|
|
||||||
|
use console_error_panic_hook;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, Registry};
|
||||||
|
use tracing_wasm::{WASMLayer, WASMLayerConfig};
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use js_sys::Uint8Array;
|
||||||
|
|
||||||
|
pub type ButtplugWASMServer = Arc<ButtplugServerDowngradeWrapper>;
|
||||||
|
|
||||||
|
pub fn send_server_message(
|
||||||
|
message: &ButtplugServerMessageCurrent,
|
||||||
|
callback: &FFICallback,
|
||||||
|
) {
|
||||||
|
let msg_array = [message.clone()];
|
||||||
|
let json_msg = vec_to_protocol_json(&msg_array);
|
||||||
|
let buf = json_msg.as_bytes();
|
||||||
|
{
|
||||||
|
let this = JsValue::null();
|
||||||
|
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
|
||||||
|
callback.call1(&this, &JsValue::from(uint8buf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub fn create_test_dcm(allow_raw_messages: bool) -> DeviceConfigurationManager {
|
||||||
|
load_protocol_configs(&None, &None, false)
|
||||||
|
.expect("If this fails, the whole library goes with it.")
|
||||||
|
.allow_raw_messages(allow_raw_messages)
|
||||||
|
.finish()
|
||||||
|
.expect("If this fails, the whole library goes with it.")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn buttplug_create_embedded_wasm_server(
|
||||||
|
callback: &FFICallback,
|
||||||
|
) -> *mut ButtplugWASMServer {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
let dcm = create_test_dcm(false);
|
||||||
|
let mut sdm = ServerDeviceManagerBuilder::new(dcm);
|
||||||
|
sdm.comm_manager(WebBluetoothCommunicationManagerBuilder::default());
|
||||||
|
let builder = ButtplugServerBuilder::new(sdm.finish().unwrap());
|
||||||
|
let server = builder.finish().unwrap();
|
||||||
|
let wrapper = Arc::new(ButtplugServerDowngradeWrapper::new(server));
|
||||||
|
let event_stream = wrapper.server_version_event_stream();
|
||||||
|
let callback = callback.clone();
|
||||||
|
async_manager::spawn(async move {
|
||||||
|
pin_mut!(event_stream);
|
||||||
|
while let Some(message) = event_stream.next().await {
|
||||||
|
send_server_message(&ButtplugServerMessageCurrent::try_from(message).unwrap(), &callback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Box::into_raw(Box::new(wrapper))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn buttplug_free_embedded_wasm_server(ptr: *mut ButtplugWASMServer) {
|
||||||
|
if !ptr.is_null() {
|
||||||
|
unsafe {
|
||||||
|
let _ = Box::from_raw(ptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn buttplug_client_send_json_message(
|
||||||
|
server_ptr: *mut ButtplugWASMServer,
|
||||||
|
buf: &[u8],
|
||||||
|
callback: &FFICallback,
|
||||||
|
) {
|
||||||
|
let server = unsafe {
|
||||||
|
assert!(!server_ptr.is_null());
|
||||||
|
&mut *server_ptr
|
||||||
|
};
|
||||||
|
let callback = callback.clone();
|
||||||
|
let serializer = ButtplugServerJSONSerializer::default();
|
||||||
|
serializer.force_message_version(&BUTTPLUG_CURRENT_MESSAGE_SPEC_VERSION);
|
||||||
|
let input_msg = serializer.deserialize(&ButtplugSerializedMessage::Text(std::str::from_utf8(buf).unwrap().to_owned())).unwrap();
|
||||||
|
async_manager::spawn(async move {
|
||||||
|
let msg = input_msg[0].clone();
|
||||||
|
let response = server.parse_message(msg).await.unwrap();
|
||||||
|
if let ButtplugServerMessageVariant::V3(response) = response {
|
||||||
|
send_server_message(&response, &callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn buttplug_activate_env_logger(_max_level: &str) {
|
||||||
|
tracing::subscriber::set_global_default(
|
||||||
|
Registry::default()
|
||||||
|
//.with(EnvFilter::new(max_level))
|
||||||
|
.with(WASMLayer::new(WASMLayerConfig::default())),
|
||||||
|
)
|
||||||
|
.expect("default global");
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { EventEmitter } from "eventemitter3";
|
||||||
|
import { ButtplugMessage } from "../core/Messages";
|
||||||
|
import { fromJSON } from "../core/MessageUtils";
|
||||||
|
|
||||||
|
export class ButtplugBrowserWebsocketConnector extends EventEmitter {
|
||||||
|
protected _ws: WebSocket | undefined;
|
||||||
|
protected _websocketConstructor: typeof WebSocket | null = null;
|
||||||
|
|
||||||
|
public constructor(private _url: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get Connected(): boolean {
|
||||||
|
return this._ws !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public connect = async (): Promise<void> => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const ws = new (this._websocketConstructor ?? WebSocket)(this._url);
|
||||||
|
const onErrorCallback = (event: Event) => {
|
||||||
|
reject(event);
|
||||||
|
};
|
||||||
|
const onCloseCallback = (event: CloseEvent) => reject(event.reason);
|
||||||
|
ws.addEventListener("open", async () => {
|
||||||
|
this._ws = ws;
|
||||||
|
try {
|
||||||
|
await this.initialize();
|
||||||
|
this._ws.addEventListener("message", (msg) => {
|
||||||
|
this.parseIncomingMessage(msg);
|
||||||
|
});
|
||||||
|
this._ws.removeEventListener("close", onCloseCallback);
|
||||||
|
this._ws.removeEventListener("error", onErrorCallback);
|
||||||
|
this._ws.addEventListener("close", this.disconnect);
|
||||||
|
resolve();
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// In websockets, our error rarely tells us much, as for security reasons
|
||||||
|
// browsers usually only throw Error Code 1006. It's up to those using this
|
||||||
|
// library to state what the problem might be.
|
||||||
|
|
||||||
|
ws.addEventListener("error", onErrorCallback);
|
||||||
|
ws.addEventListener("close", onCloseCallback);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
public disconnect = async (): Promise<void> => {
|
||||||
|
if (!this.Connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._ws!.close();
|
||||||
|
this._ws = undefined;
|
||||||
|
this.emit("disconnect");
|
||||||
|
};
|
||||||
|
|
||||||
|
public sendMessage(msg: ButtplugMessage) {
|
||||||
|
if (!this.Connected) {
|
||||||
|
throw new Error("ButtplugBrowserWebsocketConnector not connected");
|
||||||
|
}
|
||||||
|
this._ws!.send("[" + msg.toJSON() + "]");
|
||||||
|
}
|
||||||
|
|
||||||
|
public initialize = async (): Promise<void> => {
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
protected parseIncomingMessage(event: MessageEvent) {
|
||||||
|
if (typeof event.data === "string") {
|
||||||
|
const msgs = fromJSON(event.data);
|
||||||
|
this.emit("message", msgs);
|
||||||
|
} else if (event.data instanceof Blob) {
|
||||||
|
// No-op, we only use text message types.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onReaderLoad(event: Event) {
|
||||||
|
const msgs = fromJSON((event.target as FileReader).result);
|
||||||
|
this.emit("message", msgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
packages/buttplug/src/utils/ButtplugMessageSorter.ts
Normal file
65
packages/buttplug/src/utils/ButtplugMessageSorter.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/*!
|
||||||
|
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
|
||||||
|
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
|
||||||
|
* project root for full license information.
|
||||||
|
*
|
||||||
|
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Messages from "../core/Messages";
|
||||||
|
import { ButtplugError } from "../core/Exceptions";
|
||||||
|
|
||||||
|
export class ButtplugMessageSorter {
|
||||||
|
protected _counter = 1;
|
||||||
|
protected _waitingMsgs: Map<
|
||||||
|
number,
|
||||||
|
[(val: Messages.ButtplugMessage) => void, (err: Error) => void]
|
||||||
|
> = new Map();
|
||||||
|
|
||||||
|
public constructor(private _useCounter: boolean) {}
|
||||||
|
|
||||||
|
// One of the places we should actually return a promise, as we need to store
|
||||||
|
// them while waiting for them to return across the line.
|
||||||
|
// tslint:disable:promise-function-async
|
||||||
|
public PrepareOutgoingMessage(
|
||||||
|
msg: Messages.ButtplugMessage,
|
||||||
|
): Promise<Messages.ButtplugMessage> {
|
||||||
|
if (this._useCounter) {
|
||||||
|
msg.Id = this._counter;
|
||||||
|
// Always increment last, otherwise we might lose sync
|
||||||
|
this._counter += 1;
|
||||||
|
}
|
||||||
|
let res;
|
||||||
|
let rej;
|
||||||
|
const msgPromise = new Promise<Messages.ButtplugMessage>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
res = resolve;
|
||||||
|
rej = reject;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this._waitingMsgs.set(msg.Id, [res, rej]);
|
||||||
|
return msgPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ParseIncomingMessages(
|
||||||
|
msgs: Messages.ButtplugMessage[],
|
||||||
|
): Messages.ButtplugMessage[] {
|
||||||
|
const noMatch: Messages.ButtplugMessage[] = [];
|
||||||
|
for (const x of msgs) {
|
||||||
|
if (x.Id !== Messages.SYSTEM_MESSAGE_ID && this._waitingMsgs.has(x.Id)) {
|
||||||
|
const [res, rej] = this._waitingMsgs.get(x.Id)!;
|
||||||
|
// If we've gotten back an error, reject the related promise using a
|
||||||
|
// ButtplugException derived type.
|
||||||
|
if (x.Type === Messages.Error) {
|
||||||
|
rej(ButtplugError.FromError(x as Messages.Error));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
res(x);
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
noMatch.push(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return noMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/buttplug/src/utils/Utils.ts
Normal file
3
packages/buttplug/src/utils/Utils.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function getRandomInt(max: number) {
|
||||||
|
return Math.floor(Math.random() * Math.floor(max));
|
||||||
|
}
|
||||||
6
packages/buttplug/src/webbluetooth/mod.rs
Normal file
6
packages/buttplug/src/webbluetooth/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
mod webbluetooth_hardware;
|
||||||
|
mod webbluetooth_manager;
|
||||||
|
|
||||||
|
// pub use webbluetooth_hardware::{WebBluetoothHardwareConnector, WebBluetoothHardware};
|
||||||
|
pub use webbluetooth_manager::{WebBluetoothCommunicationManagerBuilder};
|
||||||
432
packages/buttplug/src/webbluetooth/webbluetooth_hardware.rs
Normal file
432
packages/buttplug/src/webbluetooth/webbluetooth_hardware.rs
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use buttplug::{
|
||||||
|
core::{
|
||||||
|
errors::ButtplugDeviceError,
|
||||||
|
message::Endpoint,
|
||||||
|
},
|
||||||
|
server::device::{
|
||||||
|
configuration::{BluetoothLESpecifier, ProtocolCommunicationSpecifier},
|
||||||
|
hardware::{
|
||||||
|
Hardware,
|
||||||
|
HardwareConnector,
|
||||||
|
HardwareEvent,
|
||||||
|
HardwareInternal,
|
||||||
|
HardwareReadCmd,
|
||||||
|
HardwareReading,
|
||||||
|
HardwareSpecializer,
|
||||||
|
HardwareSubscribeCmd,
|
||||||
|
HardwareUnsubscribeCmd,
|
||||||
|
HardwareWriteCmd,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
util::future::{ButtplugFuture, ButtplugFutureStateShared},
|
||||||
|
};
|
||||||
|
use futures::future::{self, BoxFuture};
|
||||||
|
use js_sys::{DataView, Uint8Array};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
convert::TryFrom,
|
||||||
|
fmt::{self, Debug},
|
||||||
|
};
|
||||||
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||||
|
use web_sys::{
|
||||||
|
BluetoothDevice,
|
||||||
|
BluetoothRemoteGattCharacteristic,
|
||||||
|
BluetoothRemoteGattServer,
|
||||||
|
BluetoothRemoteGattService,
|
||||||
|
Event,
|
||||||
|
MessageEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebBluetoothResultFuture = ButtplugFuture<Result<(), ButtplugDeviceError>>;
|
||||||
|
type WebBluetoothReadResultFuture = ButtplugFuture<Result<HardwareReading, ButtplugDeviceError>>;
|
||||||
|
|
||||||
|
struct BluetoothDeviceWrapper {
|
||||||
|
pub device: BluetoothDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
unsafe impl Send for BluetoothDeviceWrapper {
|
||||||
|
}
|
||||||
|
unsafe impl Sync for BluetoothDeviceWrapper {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub struct WebBluetoothHardwareConnector {
|
||||||
|
device: Option<BluetoothDeviceWrapper>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebBluetoothHardwareConnector {
|
||||||
|
pub fn new(
|
||||||
|
device: BluetoothDevice,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
device: Some(BluetoothDeviceWrapper {
|
||||||
|
device,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for WebBluetoothHardwareConnector {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let device = &self.device.as_ref();
|
||||||
|
|
||||||
|
match device {
|
||||||
|
Some(device) => {
|
||||||
|
f.debug_struct("WebBluetoothHardwareCreator")
|
||||||
|
.field("name", &device.device.name().unwrap())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
&None => {
|
||||||
|
f.debug_struct("WebBluetoothHardwareCreator")
|
||||||
|
.field("name", &"Unknown device")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl HardwareConnector for WebBluetoothHardwareConnector {
|
||||||
|
fn specifier(&self) -> ProtocolCommunicationSpecifier {
|
||||||
|
let device = &self.device.as_ref();
|
||||||
|
|
||||||
|
match device {
|
||||||
|
Some(device) => {
|
||||||
|
ProtocolCommunicationSpecifier::BluetoothLE(BluetoothLESpecifier::new_from_device(
|
||||||
|
&device.device.name().unwrap(),
|
||||||
|
&HashMap::new(),
|
||||||
|
&[]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
&None => {
|
||||||
|
ProtocolCommunicationSpecifier::BluetoothLE(BluetoothLESpecifier::new_from_device(
|
||||||
|
"Unknown device",
|
||||||
|
&HashMap::new(),
|
||||||
|
&[]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(&mut self) -> Result<Box<dyn HardwareSpecializer>, ButtplugDeviceError> {
|
||||||
|
Ok(Box::new(WebBluetoothHardwareSpecializer::new(self.device.take().unwrap())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub struct WebBluetoothHardwareSpecializer {
|
||||||
|
device: Option<BluetoothDeviceWrapper>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebBluetoothHardwareSpecializer {
|
||||||
|
fn new(device: BluetoothDeviceWrapper) -> Self {
|
||||||
|
Self {
|
||||||
|
device: Some(device),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl HardwareSpecializer for WebBluetoothHardwareSpecializer {
|
||||||
|
async fn specialize(
|
||||||
|
&mut self,
|
||||||
|
specifiers: &[ProtocolCommunicationSpecifier],
|
||||||
|
) -> Result<Hardware, ButtplugDeviceError> {
|
||||||
|
let (sender, mut receiver) = mpsc::channel(256);
|
||||||
|
let (command_sender, command_receiver) = mpsc::channel(256);
|
||||||
|
let name;
|
||||||
|
let address;
|
||||||
|
let event_sender;
|
||||||
|
// This block limits the lifetime of device. Since the compiler doesn't
|
||||||
|
// realize we move device in the spawn_local block, it'll complain that
|
||||||
|
// device's lifetime lives across the channel await, which gets all
|
||||||
|
// angry because it's a *mut u8. So this limits the visible lifetime to
|
||||||
|
// before we start waiting for the reply from the event loop.
|
||||||
|
let protocol = if let ProtocolCommunicationSpecifier::BluetoothLE(btle) = &specifiers[0] {
|
||||||
|
btle
|
||||||
|
} else {
|
||||||
|
panic!("No bluetooth, we quit");
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let device = self.device.take().unwrap().device;
|
||||||
|
name = device.name().unwrap();
|
||||||
|
address = device.id();
|
||||||
|
let (es, _) = broadcast::channel(256);
|
||||||
|
event_sender = es;
|
||||||
|
let event_loop_fut = run_webbluetooth_loop(
|
||||||
|
device,
|
||||||
|
protocol.clone(),
|
||||||
|
sender,
|
||||||
|
event_sender.clone(),
|
||||||
|
command_receiver,
|
||||||
|
);
|
||||||
|
spawn_local(async move {
|
||||||
|
event_loop_fut.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
match receiver.recv().await.unwrap() {
|
||||||
|
WebBluetoothEvent::Connected(_) => {
|
||||||
|
info!("Web Bluetooth device connected, returning device");
|
||||||
|
|
||||||
|
let device_impl: Box<dyn HardwareInternal> = Box::new(WebBluetoothHardware::new(
|
||||||
|
event_sender,
|
||||||
|
receiver,
|
||||||
|
command_sender,
|
||||||
|
));
|
||||||
|
Ok(Hardware::new(&name, &address, &[], device_impl))
|
||||||
|
}
|
||||||
|
WebBluetoothEvent::Disconnected => Err(
|
||||||
|
ButtplugDeviceError::DeviceCommunicationError(
|
||||||
|
"Could not connect to WebBluetooth device".to_string(),
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum WebBluetoothEvent {
|
||||||
|
// This is the only way we have to get our endpoints back to device creation
|
||||||
|
// right now. My god this is a mess.
|
||||||
|
Connected(Vec<Endpoint>),
|
||||||
|
Disconnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum WebBluetoothDeviceCommand {
|
||||||
|
Write(
|
||||||
|
HardwareWriteCmd,
|
||||||
|
ButtplugFutureStateShared<Result<(), ButtplugDeviceError>>,
|
||||||
|
),
|
||||||
|
Read(
|
||||||
|
HardwareReadCmd,
|
||||||
|
ButtplugFutureStateShared<Result<HardwareReading, ButtplugDeviceError>>,
|
||||||
|
),
|
||||||
|
Subscribe(
|
||||||
|
HardwareSubscribeCmd,
|
||||||
|
ButtplugFutureStateShared<Result<(), ButtplugDeviceError>>,
|
||||||
|
),
|
||||||
|
Unsubscribe(
|
||||||
|
HardwareUnsubscribeCmd,
|
||||||
|
ButtplugFutureStateShared<Result<(), ButtplugDeviceError>>,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_webbluetooth_loop(
|
||||||
|
device: BluetoothDevice,
|
||||||
|
btle_protocol: BluetoothLESpecifier,
|
||||||
|
device_local_event_sender: mpsc::Sender<WebBluetoothEvent>,
|
||||||
|
device_external_event_sender: broadcast::Sender<HardwareEvent>,
|
||||||
|
mut device_command_receiver: mpsc::Receiver<WebBluetoothDeviceCommand>,
|
||||||
|
) {
|
||||||
|
//let device = self.device.take().unwrap();
|
||||||
|
let mut char_map = HashMap::new();
|
||||||
|
let connect_future = device.gatt().unwrap().connect();
|
||||||
|
let server: BluetoothRemoteGattServer = match JsFuture::from(connect_future).await {
|
||||||
|
Ok(val) => val.into(),
|
||||||
|
Err(_) => {
|
||||||
|
device_local_event_sender
|
||||||
|
.send(WebBluetoothEvent::Disconnected)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (service_uuid, service_endpoints) in btle_protocol.services() {
|
||||||
|
let service = if let Ok(serv) =
|
||||||
|
JsFuture::from(server.get_primary_service_with_str(&service_uuid.to_string())).await
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"Service {} found on device {}",
|
||||||
|
service_uuid,
|
||||||
|
device.name().unwrap()
|
||||||
|
);
|
||||||
|
BluetoothRemoteGattService::from(serv)
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
"Service {} not found on device {}",
|
||||||
|
service_uuid,
|
||||||
|
device.name().unwrap()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for (chr_name, chr_uuid) in service_endpoints.iter() {
|
||||||
|
info!("Connecting chr {} {}", chr_name, chr_uuid.to_string());
|
||||||
|
let char: BluetoothRemoteGattCharacteristic =
|
||||||
|
JsFuture::from(service.get_characteristic_with_str(&chr_uuid.to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into();
|
||||||
|
char_map.insert(chr_name.clone(), char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let event_sender = device_external_event_sender.clone();
|
||||||
|
let id = device.id().clone();
|
||||||
|
let ondisconnected_callback = Closure::wrap(Box::new(move |_: Event| {
|
||||||
|
info!("device disconnected!");
|
||||||
|
event_sender
|
||||||
|
.send(HardwareEvent::Disconnected(id.clone()))
|
||||||
|
.unwrap();
|
||||||
|
}) as Box<dyn FnMut(Event)>);
|
||||||
|
// set disconnection event handler on BluetoothDevice
|
||||||
|
device.set_ongattserverdisconnected(Some(ondisconnected_callback.as_ref().unchecked_ref()));
|
||||||
|
ondisconnected_callback.forget();
|
||||||
|
}
|
||||||
|
//let web_btle_device = WebBluetoothDeviceImpl::new(device, char_map);
|
||||||
|
info!("device created!");
|
||||||
|
let endpoints = char_map.keys().into_iter().cloned().collect();
|
||||||
|
device_local_event_sender
|
||||||
|
.send(WebBluetoothEvent::Connected(endpoints))
|
||||||
|
.await;
|
||||||
|
while let Some(msg) = device_command_receiver.recv().await {
|
||||||
|
match msg {
|
||||||
|
WebBluetoothDeviceCommand::Write(write_cmd, waker) => {
|
||||||
|
debug!("Writing to endpoint {:?}", write_cmd.endpoint());
|
||||||
|
let chr = char_map.get(&write_cmd.endpoint()).unwrap().clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(&write_cmd.data().clone())) };
|
||||||
|
JsFuture::from(chr.write_value_with_u8_array(&uint8buf).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
waker.set_reply(Ok(()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WebBluetoothDeviceCommand::Read(read_cmd, waker) => {
|
||||||
|
debug!("Writing to endpoint {:?}", read_cmd.endpoint());
|
||||||
|
let chr = char_map.get(&read_cmd.endpoint()).unwrap().clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
let read_value = JsFuture::from(chr.read_value()).await.unwrap();
|
||||||
|
let data_view = DataView::try_from(read_value).unwrap();
|
||||||
|
let mut body = vec![0; data_view.byte_length() as usize];
|
||||||
|
Uint8Array::new(&data_view).copy_to(&mut body[..]);
|
||||||
|
let reading = HardwareReading::new(read_cmd.endpoint(), &body);
|
||||||
|
waker.set_reply(Ok(reading));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WebBluetoothDeviceCommand::Subscribe(subscribe_cmd, waker) => {
|
||||||
|
debug!("Subscribing to endpoint {:?}", subscribe_cmd.endpoint());
|
||||||
|
let chr = char_map.get(&subscribe_cmd.endpoint()).unwrap().clone();
|
||||||
|
let ep = subscribe_cmd.endpoint();
|
||||||
|
let event_sender = device_external_event_sender.clone();
|
||||||
|
let id = device.id().clone();
|
||||||
|
let onchange_callback = Closure::wrap(Box::new(move |e: MessageEvent| {
|
||||||
|
let event_chr: BluetoothRemoteGattCharacteristic =
|
||||||
|
BluetoothRemoteGattCharacteristic::from(JsValue::from(e.target().unwrap()));
|
||||||
|
let value = Uint8Array::new_with_byte_offset(
|
||||||
|
&JsValue::from(event_chr.value().unwrap().buffer()),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let value_vec = value.to_vec();
|
||||||
|
debug!("Subscription notification from {}: {:?}", ep, value_vec);
|
||||||
|
event_sender
|
||||||
|
.send(HardwareEvent::Notification(id.clone(), ep, value_vec))
|
||||||
|
.unwrap();
|
||||||
|
}) as Box<dyn FnMut(MessageEvent)>);
|
||||||
|
// set message event handler on WebSocket
|
||||||
|
chr.set_oncharacteristicvaluechanged(Some(onchange_callback.as_ref().unchecked_ref()));
|
||||||
|
onchange_callback.forget();
|
||||||
|
spawn_local(async move {
|
||||||
|
JsFuture::from(chr.start_notifications()).await.unwrap();
|
||||||
|
debug!("Endpoint subscribed");
|
||||||
|
waker.set_reply(Ok(()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WebBluetoothDeviceCommand::Unsubscribe(_unsubscribe_cmd, _waker) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!("run_webbluetooth_loop exited!");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct WebBluetoothHardware {
|
||||||
|
device_command_sender: mpsc::Sender<WebBluetoothDeviceCommand>,
|
||||||
|
device_event_receiver: mpsc::Receiver<WebBluetoothEvent>,
|
||||||
|
event_sender: broadcast::Sender<HardwareEvent>,
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
unsafe impl Send for WebBluetoothHardware {
|
||||||
|
}
|
||||||
|
unsafe impl Sync for WebBluetoothHardware {
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
impl WebBluetoothHardware {
|
||||||
|
pub fn new(
|
||||||
|
event_sender: broadcast::Sender<HardwareEvent>,
|
||||||
|
device_event_receiver: mpsc::Receiver<WebBluetoothEvent>,
|
||||||
|
device_command_sender: mpsc::Sender<WebBluetoothDeviceCommand>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
event_sender,
|
||||||
|
device_event_receiver,
|
||||||
|
device_command_sender,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HardwareInternal for WebBluetoothHardware {
|
||||||
|
fn event_stream(&self) -> broadcast::Receiver<HardwareEvent> {
|
||||||
|
self.event_sender.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disconnect(&self) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> {
|
||||||
|
Box::pin(future::ready(Ok(())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_value(
|
||||||
|
&self,
|
||||||
|
msg: &HardwareReadCmd,
|
||||||
|
) -> BoxFuture<'static, Result<HardwareReading, ButtplugDeviceError>> {
|
||||||
|
let sender = self.device_command_sender.clone();
|
||||||
|
let msg = msg.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
let fut = WebBluetoothReadResultFuture::default();
|
||||||
|
let waker = fut.get_state_clone();
|
||||||
|
sender
|
||||||
|
.send(WebBluetoothDeviceCommand::Read(msg, waker))
|
||||||
|
.await;
|
||||||
|
fut.await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_value(&self, msg: &HardwareWriteCmd) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> {
|
||||||
|
let sender = self.device_command_sender.clone();
|
||||||
|
let msg = msg.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
let fut = WebBluetoothResultFuture::default();
|
||||||
|
let waker = fut.get_state_clone();
|
||||||
|
sender
|
||||||
|
.send(WebBluetoothDeviceCommand::Write(msg.clone(), waker))
|
||||||
|
.await;
|
||||||
|
fut.await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe(&self, msg: &HardwareSubscribeCmd) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> {
|
||||||
|
let sender = self.device_command_sender.clone();
|
||||||
|
let msg = msg.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
let fut = WebBluetoothResultFuture::default();
|
||||||
|
let waker = fut.get_state_clone();
|
||||||
|
sender
|
||||||
|
.send(WebBluetoothDeviceCommand::Subscribe(msg.clone(), waker))
|
||||||
|
.await;
|
||||||
|
fut.await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsubscribe(&self, _msg: &HardwareUnsubscribeCmd) -> BoxFuture<'static, Result<(), ButtplugDeviceError>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
error!("IMPLEMENT UNSUBSCRIBE FOR WEBBLUETOOTH WASM");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
134
packages/buttplug/src/webbluetooth/webbluetooth_manager.rs
Normal file
134
packages/buttplug/src/webbluetooth/webbluetooth_manager.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
use super::webbluetooth_hardware::WebBluetoothHardwareConnector;
|
||||||
|
|
||||||
|
use buttplug::{
|
||||||
|
core::ButtplugResultFuture,
|
||||||
|
server::device::{
|
||||||
|
configuration::{ProtocolCommunicationSpecifier},
|
||||||
|
hardware::communication::{
|
||||||
|
HardwareCommunicationManager, HardwareCommunicationManagerBuilder,
|
||||||
|
HardwareCommunicationManagerEvent,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
use futures::future;
|
||||||
|
use js_sys::Array;
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||||
|
use web_sys::BluetoothDevice;
|
||||||
|
use crate::create_test_dcm;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct WebBluetoothCommunicationManagerBuilder {
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HardwareCommunicationManagerBuilder for WebBluetoothCommunicationManagerBuilder {
|
||||||
|
fn finish(&mut self, sender: Sender<HardwareCommunicationManagerEvent>) -> Box<dyn HardwareCommunicationManager> {
|
||||||
|
Box::new(WebBluetoothCommunicationManager {
|
||||||
|
sender,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WebBluetoothCommunicationManager {
|
||||||
|
sender: Sender<HardwareCommunicationManagerEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
// Use `js_namespace` here to bind `console.log(..)` instead of just
|
||||||
|
// `log(..)`
|
||||||
|
#[wasm_bindgen(js_namespace = console)]
|
||||||
|
fn log(s: &str);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HardwareCommunicationManager for WebBluetoothCommunicationManager {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"WebBluetoothCommunicationManager"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_scan(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_scanning(&mut self) -> ButtplugResultFuture {
|
||||||
|
info!("WebBluetooth manager scanning");
|
||||||
|
let sender_clone = self.sender.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
// Build the filter block
|
||||||
|
let nav = web_sys::window().unwrap().navigator();
|
||||||
|
if nav.bluetooth().is_none() {
|
||||||
|
error!("WebBluetooth is not supported on this browser");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info!("WebBluetooth supported by browser, continuing with scan.");
|
||||||
|
// HACK: As of buttplug v5, we can't just create a HardwareCommunicationManager anymore. This is
|
||||||
|
// using a test method to create a filled out DCM, which will work for now because there's no
|
||||||
|
// way for anyone to add device configurations through FFI yet anyways.
|
||||||
|
let config_manager = create_test_dcm(false);
|
||||||
|
let options = web_sys::RequestDeviceOptions::new();
|
||||||
|
let filters = Array::new();
|
||||||
|
let optional_services = Array::new();
|
||||||
|
for vals in config_manager.protocol_device_configurations().iter() {
|
||||||
|
for config in vals.1 {
|
||||||
|
if let ProtocolCommunicationSpecifier::BluetoothLE(btle) = &config {
|
||||||
|
for name in btle.names() {
|
||||||
|
let filter = web_sys::BluetoothLeScanFilterInit::new();
|
||||||
|
if name.contains("*") {
|
||||||
|
let mut name_clone = name.clone();
|
||||||
|
name_clone.pop();
|
||||||
|
filter.set_name_prefix(&name_clone);
|
||||||
|
} else {
|
||||||
|
filter.set_name(&name);
|
||||||
|
}
|
||||||
|
filters.push(&filter.into());
|
||||||
|
}
|
||||||
|
for (service, _) in btle.services() {
|
||||||
|
optional_services.push(&service.to_string().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.set_filters(&filters.into());
|
||||||
|
options.set_optional_services(&optional_services.into());
|
||||||
|
let nav = web_sys::window().unwrap().navigator();
|
||||||
|
//nav.bluetooth().get_availability();
|
||||||
|
//JsFuture::from(nav.bluetooth().request_device()).await;
|
||||||
|
match JsFuture::from(nav.bluetooth().unwrap().request_device(&options)).await {
|
||||||
|
Ok(device) => {
|
||||||
|
let bt_device = BluetoothDevice::from(device);
|
||||||
|
if bt_device.name().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let name = bt_device.name().unwrap();
|
||||||
|
let address = bt_device.id();
|
||||||
|
let device_creator = Box::new(WebBluetoothHardwareConnector::new(bt_device));
|
||||||
|
if sender_clone
|
||||||
|
.send(HardwareCommunicationManagerEvent::DeviceFound {
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
creator: device_creator,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
error!("Device manager receiver dropped, cannot send device found message.");
|
||||||
|
} else {
|
||||||
|
info!("WebBluetooth device found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error while trying to start bluetooth scan: {:?}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = sender_clone
|
||||||
|
.send(HardwareCommunicationManagerEvent::ScanningFinished)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
Box::pin(future::ready(Ok(())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_scanning(&mut self) -> ButtplugResultFuture {
|
||||||
|
Box::pin(future::ready(Ok(())))
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/buttplug/tsconfig.json
Normal file
13
packages/buttplug/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"module": "esnext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
18
packages/buttplug/vite.config.ts
Normal file
18
packages/buttplug/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import path from "path";
|
||||||
|
import wasm from "vite-plugin-wasm";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [wasm()], // include wasm plugin
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, "src/index.ts"),
|
||||||
|
name: "buttplug",
|
||||||
|
fileName: "index",
|
||||||
|
formats: ["es"], // this is important
|
||||||
|
},
|
||||||
|
minify: false, // for demo purposes
|
||||||
|
target: "esnext", // this is important as well
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||
17
packages/frontend/.dockerignore
Normal file
17
packages/frontend/.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
README.md
|
||||||
|
.npmrc
|
||||||
|
.prettierrc
|
||||||
|
.eslintrc.cjs
|
||||||
|
.graphqlrc
|
||||||
|
.editorconfig
|
||||||
|
.svelte-kit
|
||||||
|
.vscode
|
||||||
|
node_modules
|
||||||
|
build
|
||||||
|
package
|
||||||
|
**/.env
|
||||||
6
packages/frontend/.env
Normal file
6
packages/frontend/.env
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
PUBLIC_API_URL=
|
||||||
|
PUBLIC_URL=
|
||||||
|
PUBLIC_UMAMI_ID=
|
||||||
|
LETTERSPACE_API_URL=
|
||||||
|
LETTERSPACE_API_KEY=
|
||||||
|
LETTERSPACE_LIST_ID=
|
||||||
3
packages/frontend/.gitignore
vendored
Normal file
3
packages/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
16
packages/frontend/components.json
Normal file
16
packages/frontend/components.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
|
"tailwind": {
|
||||||
|
"css": "src/app.css",
|
||||||
|
"baseColor": "slate"
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "$lib/components",
|
||||||
|
"utils": "$lib/utils",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
|
},
|
||||||
|
"typescript": true,
|
||||||
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
|
}
|
||||||
16
packages/frontend/jsrepo.json
Normal file
16
packages/frontend/jsrepo.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://unpkg.com/jsrepo@2.4.9/schemas/project-config.json",
|
||||||
|
"repos": ["@ieedan/shadcn-svelte-extras"],
|
||||||
|
"includeTests": false,
|
||||||
|
"includeDocs": false,
|
||||||
|
"watermark": true,
|
||||||
|
"formatter": "prettier",
|
||||||
|
"configFiles": {},
|
||||||
|
"paths": {
|
||||||
|
"*": "$lib/blocks",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"actions": "$lib/actions",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"utils": "$lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
50
packages/frontend/package.json
Normal file
50
packages/frontend/package.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "@sexy.pivoine.art/frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "valknarogg",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"start": "node ./build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/ri": "^1.2.5",
|
||||||
|
"@iconify/tailwind4": "^1.0.6",
|
||||||
|
"@internationalized/date": "^3.8.2",
|
||||||
|
"@lucide/svelte": "^0.544.0",
|
||||||
|
"@sveltejs/adapter-node": "^5.3.1",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.9",
|
||||||
|
"@sveltejs/kit": "^2.37.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.1.4",
|
||||||
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@tsconfig/svelte": "^5.0.5",
|
||||||
|
"bits-ui": "2.11.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"glob": "^11.0.3",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
|
"super-sitemap": "^1.0.5",
|
||||||
|
"svelte": "^5.38.6",
|
||||||
|
"svelte-sonner": "^1.0.5",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwind-variants": "^1.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"tw-animate-css": "^1.3.8",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vite": "^7.1.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@directus/sdk": "^20.0.3",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@opentelemetry/auto-instrumentations-node": "^0.64.6",
|
||||||
|
"@sexy.pivoine.art/buttplug": "workspace:*",
|
||||||
|
"javascript-time-ago": "^2.5.11",
|
||||||
|
"media-chrome": "^4.13.1",
|
||||||
|
"svelte-i18n": "^4.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
226
packages/frontend/src/app.css
Normal file
226
packages/frontend/src/app.css
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@plugin "@iconify/tailwind4";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--animate-vibrate: vibrate 0.3s linear infinite;
|
||||||
|
--animate-fade-in: fadeIn 0.3s ease-out;
|
||||||
|
--animate-slide-up: slideUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--animate-zoom-in: zoomIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--animate-pulse-glow: pulseGlow 2s infinite;
|
||||||
|
|
||||||
|
@keyframes vibrate {
|
||||||
|
0% {
|
||||||
|
transform: translate(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
20% {
|
||||||
|
transform: translate(-2px, 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translate(-2px, -2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
80% {
|
||||||
|
transform: translate(2px, -2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes zoomIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseGlow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
boxShadow: 0 0 20px rgba(183, 0, 217, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
boxShadow: 0 0 40px rgba(183, 0, 217, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||||
|
so weve added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
outline-color: color-mix(in oklab, var(--ring) 50%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--border);
|
||||||
|
outline-color: var(--ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h2 {
|
||||||
|
@apply text-2xl font-bold mt-8 mb-4 text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h3 {
|
||||||
|
@apply text-xl font-semibold mt-6 mb-3 text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose p {
|
||||||
|
@apply mb-4 leading-relaxed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul {
|
||||||
|
@apply mb-4 pl-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li {
|
||||||
|
@apply mb-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--default-font-family: "Noto Sans", sans-serif;
|
||||||
|
--background: oklch(0.98 0.01 320);
|
||||||
|
--foreground: oklch(0.08 0.02 280);
|
||||||
|
--muted: oklch(0.95 0.01 280);
|
||||||
|
--muted-foreground: oklch(0.4 0.02 280);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(0.99 0.005 320);
|
||||||
|
--card-foreground: oklch(0.08 0.02 280);
|
||||||
|
--border: oklch(0.85 0.02 280);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--primary: oklch(56.971% 0.27455 319.257);
|
||||||
|
--primary-foreground: oklch(0.98 0.01 320);
|
||||||
|
--secondary: oklch(0.92 0.02 260);
|
||||||
|
--secondary-foreground: oklch(0.15 0.05 260);
|
||||||
|
--accent: oklch(0.45 0.35 280);
|
||||||
|
--accent-foreground: oklch(0.98 0.01 280);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--ring: oklch(0.55 0.3 320);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.08 0.02 280);
|
||||||
|
--foreground: oklch(0.98 0.01 280);
|
||||||
|
--muted: oklch(0.12 0.03 280);
|
||||||
|
--muted-foreground: oklch(0.6 0.02 280);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.1 0.02 280);
|
||||||
|
--card-foreground: oklch(0.95 0.01 280);
|
||||||
|
--border: oklch(0.2 0.05 280);
|
||||||
|
--input: oklch(1 0 0 / 0.15);
|
||||||
|
--primary: oklch(0.65 0.25 320);
|
||||||
|
--primary-foreground: oklch(0.98 0.01 320);
|
||||||
|
--secondary: oklch(0.15 0.05 260);
|
||||||
|
--secondary-foreground: oklch(0.9 0.02 260);
|
||||||
|
--accent: oklch(0.55 0.3 280);
|
||||||
|
--accent-foreground: oklch(0.98 0.01 280);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--ring: oklch(0.65 0.25 320);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 0.1);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-mono: var(--font-mono);
|
||||||
|
--font-serif: var(--font-serif);
|
||||||
|
}
|
||||||
24
packages/frontend/src/app.d.ts
vendored
Normal file
24
packages/frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
|
||||||
|
import type { AuthStatus } from "$lib/types";
|
||||||
|
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
interface Locals {
|
||||||
|
authStatus: AuthStatus;
|
||||||
|
}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
interface Window {
|
||||||
|
sidebar: {
|
||||||
|
addPanel: () => void;
|
||||||
|
};
|
||||||
|
opera: object;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
24
packages/frontend/src/app.html
Normal file
24
packages/frontend/src/app.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400..700&family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body data-sveltekit-preload-data="hover" class="dark">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
27
packages/frontend/src/hooks.server.ts
Normal file
27
packages/frontend/src/hooks.server.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { isAuthenticated } from "$lib/services";
|
||||||
|
|
||||||
|
export async function handle({ event, resolve }) {
|
||||||
|
const { cookies, locals } = event;
|
||||||
|
|
||||||
|
const token = cookies.get("directus_session_token");
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
locals.authStatus = await isAuthenticated(token);
|
||||||
|
// if (locals.authStatus.authenticated) {
|
||||||
|
// cookies.set('directus_refresh_token', locals.authStatus.data!.refresh_token!, {
|
||||||
|
// httpOnly: true,
|
||||||
|
// secure: true,
|
||||||
|
// domain: '.pivoine.art',
|
||||||
|
// path: '/'
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
} else {
|
||||||
|
locals.authStatus = { authenticated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resolve(event, {
|
||||||
|
filterSerializedResponseHeaders: (key) => {
|
||||||
|
return key.toLowerCase() === "content-type";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "$lib/components/ui/dialog";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Separator } from "$lib/components/ui/separator";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
const AGE_VERIFICATION_KEY = "age-verified";
|
||||||
|
|
||||||
|
let isOpen = true;
|
||||||
|
|
||||||
|
function handleAgeConfirmation() {
|
||||||
|
localStorage.setItem(AGE_VERIFICATION_KEY, "true");
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const storedVerification = localStorage.getItem(AGE_VERIFICATION_KEY);
|
||||||
|
if (storedVerification === "true") {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open={isOpen}>
|
||||||
|
<DialogContent
|
||||||
|
class="sm:max-w-md"
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
showCloseButton={false}
|
||||||
|
>
|
||||||
|
<DialogHeader class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 shrink-0 grow-0 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span class="text-primary-foreground text-sm"
|
||||||
|
>{$_("age_verification_dialog.age")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
||||||
|
>{$_("age_verification_dialog.title")}</DialogTitle
|
||||||
|
>
|
||||||
|
<DialogDescription class="text-left text-sm">
|
||||||
|
{$_("age_verification_dialog.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Separator class="my-4" />
|
||||||
|
|
||||||
|
<!-- Close Button -->
|
||||||
|
<div class="flex justify-end gap-4">
|
||||||
|
<Button variant="destructive" href={$_("age_verification_dialog.exit_url")} size="sm">
|
||||||
|
{$_("age_verification_dialog.exit")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onclick={handleAgeConfirmation}
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--check-line]"></span>
|
||||||
|
{$_("age_verification_dialog.confirm")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!-- Advanced Plasma Background -->
|
||||||
|
<div class="absolute inset-0 pointer-events-none">
|
||||||
|
<!-- Primary gradient layers -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-br from-primary/6 via-accent/10 to-primary/4 opacity-60"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-tl from-accent/4 via-primary/8 to-accent/6 opacity-40"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Large floating orbs -->
|
||||||
|
<!-- <div
|
||||||
|
class="absolute top-20 left-20 w-80 h-80 bg-gradient-to-br from-primary/12 via-accent/18 to-primary/8 rounded-full blur-3xl animate-blob-slow"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-20 right-20 w-96 h-96 bg-gradient-to-tl from-accent/12 via-primary/18 to-accent/8 rounded-full blur-3xl animate-blob-slow animation-delay-6000"
|
||||||
|
></div> -->
|
||||||
|
|
||||||
|
<!-- Medium morphing elements -->
|
||||||
|
<!-- <div
|
||||||
|
class="absolute top-1/2 left-1/3 w-64 h-64 bg-gradient-to-r from-primary/10 via-accent/15 to-primary/8 rounded-full blur-2xl animate-blob-reverse animation-delay-3000"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-1/3 right-1/3 w-72 h-72 bg-gradient-to-l from-accent/10 via-primary/15 to-accent/8 rounded-full blur-2xl animate-blob-reverse animation-delay-9000"
|
||||||
|
></div> -->
|
||||||
|
|
||||||
|
<!-- Soft particle effects -->
|
||||||
|
<!-- <div
|
||||||
|
class="absolute top-1/4 right-1/4 w-48 h-48 bg-gradient-to-br from-primary/15 to-accent/12 rounded-full blur-xl animate-float animation-delay-2000"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-1/4 left-1/4 w-56 h-56 bg-gradient-to-tl from-accent/15 to-primary/12 rounded-full blur-xl animate-float animation-delay-8000"
|
||||||
|
></div> -->
|
||||||
|
|
||||||
|
<!-- Premium glassmorphism overlay -->
|
||||||
|
<!-- <div
|
||||||
|
class="absolute inset-0 bg-gradient-to-br from-primary/2 via-transparent to-accent/3 backdrop-blur-[1px]"
|
||||||
|
></div> -->
|
||||||
|
|
||||||
|
<!-- Animated Plasma Background -->
|
||||||
|
<div
|
||||||
|
class="absolute top-1/3 left-1/3 w-72 h-72 bg-gradient-to-r from-accent/20 via-primary/25 to-accent/15 rounded-full blur-2xl animate-blob"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-1/3 right-1/3 w-88 h-88 bg-gradient-to-r from-primary/20 via-accent/25 to-primary/15 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute top-1/2 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/20 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1000"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Global Plasma Background -->
|
||||||
|
<!-- <div
|
||||||
|
class="absolute top-32 right-32 w-72 h-72 bg-gradient-to-r from-accent/18 via-primary/22 to-accent/12 rounded-full blur-3xl animate-blob"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-32 left-32 w-88 h-88 bg-gradient-to-r from-primary/18 via-accent/22 to-primary/12 rounded-full blur-3xl animate-blob-reverse animation-delay-3000"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute top-2/3 right-1/4 w-64 h-64 bg-gradient-to-r from-accent/15 via-primary/18 to-accent/10 rounded-full blur-2xl animate-float animation-delay-1500"
|
||||||
|
></div> -->
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const { isMobileMenuOpen = $bindable(), label, onclick } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="block rounded-full cursor-pointer"
|
||||||
|
onclick={onclick}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative flex overflow-hidden items-center justify-center rounded-full w-[50px] h-[50px] transform transition-all duration-200 shadow-md opacity-90 translate-x-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-between w-[16px] h-[10px] transform transition-all duration-300 origin-center overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class={`bg-white h-[2px] w-7 rounded transform transition-all duration-300 delay-75 ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class={`bg-white h-[2px] w-7 transform transition-all duration-300 origin-left delay-150 ${isMobileMenuOpen ? 'translate-x-10' : ''}`}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={`absolute items-center justify-between transform transition-all duration-500 top-6.5 -translate-x-10 flex w-0 ${isMobileMenuOpen ? 'translate-x-0 w-12' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 rotate-0 delay-300 ${isMobileMenuOpen ? 'rotate-45' : ''}`}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class={`absolute bg-white h-[2px] w-4 transform transition-all duration-500 -rotate-0 delay-300 ${isMobileMenuOpen ? '-rotate-45' : ''}`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import { Slider } from "$lib/components/ui/slider";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
|
||||||
|
import type { BluetoothDevice } from "$lib/types";
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { ActuatorType } from "@sexy.pivoine.art/buttplug";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
device: BluetoothDevice;
|
||||||
|
onChange: (scalarIndex: number, val: number) => void;
|
||||||
|
onStop: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { device, onChange, onStop }: Props = $props();
|
||||||
|
|
||||||
|
function getBatteryColor(level: number) {
|
||||||
|
if (!device.info.hasBattery) {
|
||||||
|
return "text-gray-400";
|
||||||
|
}
|
||||||
|
if (level > 60) return "text-green-400";
|
||||||
|
if (level > 30) return "text-yellow-400";
|
||||||
|
return "text-red-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBatteryBgColor(level: number) {
|
||||||
|
if (!device.info.hasBattery) {
|
||||||
|
return "bg-gray-400/20";
|
||||||
|
}
|
||||||
|
if (level > 60) return "bg-green-400/20";
|
||||||
|
if (level > 30) return "bg-yellow-400/20";
|
||||||
|
return "bg-red-400/20";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScalarAnimations() {
|
||||||
|
const cmds: [{ ActuatorType: typeof ActuatorType }] =
|
||||||
|
device.info.messageAttributes.ScalarCmd;
|
||||||
|
return cmds
|
||||||
|
.filter((_, i: number) => !!device.actuatorValues[i])
|
||||||
|
.map(({ ActuatorType }) => `animate-${ActuatorType.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive() {
|
||||||
|
const cmds: [{ ActuatorType: typeof ActuatorType }] =
|
||||||
|
device.info.messageAttributes.ScalarCmd;
|
||||||
|
return cmds.some((_, i: number) => !!device.actuatorValues[i]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
class="group hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 border-border/50 hover:border-primary/30 bg-card/50 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<CardHeader class="pb-3">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-accent/20 border border-primary/30 flex shrink-0 grow-0"
|
||||||
|
>
|
||||||
|
<span class={cn([...getScalarAnimations(), "icon-[ri--rocket-line] w-5 h-5 text-primary"])}></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
class={`font-semibold text-card-foreground group-hover:text-primary transition-colors`}
|
||||||
|
>
|
||||||
|
{device.name}
|
||||||
|
</h3>
|
||||||
|
<!-- <p class="text-sm text-muted-foreground">
|
||||||
|
{device.deviceType}
|
||||||
|
</p> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class={`${isActive() ? "cursor-pointer" : ""} flex items-center gap-2`} onclick={() => isActive() && onStop()}>
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="w-2 h-2 rounded-full {isActive()
|
||||||
|
? 'bg-green-400'
|
||||||
|
: 'bg-red-400'}"
|
||||||
|
></div>
|
||||||
|
{#if isActive()}
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 w-2 h-2 rounded-full bg-green-400 animate-ping opacity-75"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium {isActive()
|
||||||
|
? 'text-green-400'
|
||||||
|
: 'text-red-400'}"
|
||||||
|
>
|
||||||
|
{isActive()
|
||||||
|
? $_("device_card.active")
|
||||||
|
: $_("device_card.paused")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<!-- Current Value -->
|
||||||
|
<!-- <div
|
||||||
|
class="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/30"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>{$_("device_card.current_value")}</span
|
||||||
|
>
|
||||||
|
<span class="font-medium text-card-foreground">{device.currentValue}</span
|
||||||
|
>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<!-- Battery Level -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="icon-[ri--battery-2-charge-line] w-4 h-4 {getBatteryColor(
|
||||||
|
device.batteryLevel,
|
||||||
|
)}"
|
||||||
|
></span>
|
||||||
|
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
|
||||||
|
</div>
|
||||||
|
{#if device.info.hasBattery}
|
||||||
|
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
|
||||||
|
{device.batteryLevel}%
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-muted/50 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all duration-500 {getBatteryBgColor(
|
||||||
|
device.batteryLevel,
|
||||||
|
)} bg-gradient-to-r from-current to-current/80"
|
||||||
|
style="width: {device.batteryLevel}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Seen -->
|
||||||
|
<!-- <div
|
||||||
|
class="flex items-center justify-between text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>{$_("device_card.last_seen")}</span>
|
||||||
|
<span>{device.lastSeen.toLocaleTimeString()}</span>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<!-- Action Button -->
|
||||||
|
{#each device.info.messageAttributes.ScalarCmd as scalarCmd}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for={`device-${device.info.index}-${scalarCmd.Index}`}
|
||||||
|
>{$_(
|
||||||
|
`device_card.actuator_types.${scalarCmd.ActuatorType.toLowerCase()}`,
|
||||||
|
)}</Label
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
id={`device-${device.info.index}-${scalarCmd.Index}`}
|
||||||
|
type="single"
|
||||||
|
value={device.actuatorValues[scalarCmd.Index]}
|
||||||
|
onValueChange={(val) => onChange(scalarCmd.Index, val)}
|
||||||
|
max={scalarCmd.StepCount}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
121
packages/frontend/src/lib/components/footer/footer.svelte
Normal file
121
packages/frontend/src/lib/components/footer/footer.svelte
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
|
||||||
|
import Logo from "../logo/logo.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
class="bg-gradient-to-t from-card/95 to-card/85 backdrop-blur-xl mt-20 shadow-2xl shadow-primary/10"
|
||||||
|
>
|
||||||
|
<div class="container mx-auto px-4 py-12">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-3 text-xl font-bold">
|
||||||
|
<Logo />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">{$_("brand.description")}</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a
|
||||||
|
aria-label="Email"
|
||||||
|
href="mailto:{$_('footer.contact.email')}"
|
||||||
|
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--mail-line] w-4 h-4 text-primary"></span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
aria-label="X"
|
||||||
|
href="https://www.x.com/{$_('footer.contact.x')}"
|
||||||
|
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--twitter-x-line] w-4 h-4 text-primary"></span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
aria-label="YouTube"
|
||||||
|
href="https://www.youtube.com/@{$_('footer.contact.youtube')}"
|
||||||
|
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--youtube-line] w-4 h-4 text-primary"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Links -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold text-foreground">
|
||||||
|
{$_("footer.quick_links")}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<a
|
||||||
|
href="/models"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.models")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/videos"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.videos")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/magazine"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.magazine")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/about"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.about")}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Support -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold text-foreground">{$_("footer.support")}</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<a
|
||||||
|
href="mailto:{$_('footer.contact_support_email')}"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.contact_support")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="mailto:{$_('footer.model_applications_email')}"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.model_applications")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/faq"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.faq")}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legal -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-semibold text-foreground">{$_("footer.legal")}</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<a
|
||||||
|
href="/legal"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.privacy_policy")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/legal"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.terms_of_service")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/imprint"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>{$_("footer.imprint")}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-border/50 mt-8 pt-8 text-center">
|
||||||
|
<p class="text-sm text-muted-foreground">{$_("footer.copyright")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
120
packages/frontend/src/lib/components/girls/girls.svelte
Normal file
120
packages/frontend/src/lib/components/girls/girls.svelte
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<div class="w-full h-auto">
|
||||||
|
<svg
|
||||||
|
version="1.0"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 1280.000000 904.000000"
|
||||||
|
stroke-width="5"
|
||||||
|
stroke="#ce47eb"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
<metadata>
|
||||||
|
Created by potrace 1.15, written by Peter Selinger 2001-2017
|
||||||
|
</metadata>
|
||||||
|
<g transform="translate(0.000000,904.000000) scale(0.100000,-0.100000)">
|
||||||
|
<path
|
||||||
|
d="M7930 7043 c-73 -10 -95 -18 -134 -51 -25 -20 -66 -53 -91 -72 -26
|
||||||
|
-19 -69 -66 -96 -104 -116 -164 -130 -314 -59 -664 32 -164 36 -217 18 -256
|
||||||
|
-13 -30 -14 -30 -140 -52 -75 -12 -105 -13 -129 -5 -18 6 -59 11 -93 11 -123
|
||||||
|
-1 -213 -66 -379 -275 -245 -308 -501 -567 -686 -693 l-92 -64 -82 7 c-53 5
|
||||||
|
-88 13 -100 23 -21 18 -66 20 -167 7 -73 -9 -124 -31 -159 -69 -22 -23 -23
|
||||||
|
-31 -18 -94 6 -58 4 -71 -11 -84 -44 -40 -203 -119 -295 -149 -56 -18 -144
|
||||||
|
-50 -195 -71 -50 -21 -138 -51 -195 -67 -232 -65 -369 -131 -595 -284 -182
|
||||||
|
-124 -172 -123 -208 -27 -23 60 -39 81 -189 245 -279 305 -319 354 -368 458
|
||||||
|
-46 94 -47 98 -32 127 8 16 15 36 15 43 0 8 14 41 30 72 17 31 30 63 30 70 0
|
||||||
|
7 7 18 15 25 8 7 15 26 15 42 0 42 15 65 49 71 17 4 37 17 46 30 14 23 14 30
|
||||||
|
-9 101 -28 88 -21 130 22 141 20 5 23 10 18 31 -4 13 -1 34 5 46 13 25 33 239
|
||||||
|
31 336 0 42 -8 78 -23 108 -31 65 -121 158 -209 217 -41 28 -77 55 -79 60 -2
|
||||||
|
5 -17 24 -33 43 -23 26 -48 39 -111 58 -183 55 -239 61 -361 36 -156 -33 -333
|
||||||
|
-185 -425 -368 -72 -143 -93 -280 -96 -622 -2 -240 -5 -288 -24 -379 -12 -57
|
||||||
|
-30 -120 -40 -140 -11 -20 -61 -84 -113 -142 -52 -58 -105 -121 -118 -140 -13
|
||||||
|
-19 -45 -58 -72 -88 -93 -106 -127 -193 -237 -616 -33 -127 -67 -251 -76 -275
|
||||||
|
-9 -25 -48 -153 -86 -285 -78 -264 -163 -502 -334 -935 -135 -340 -194 -526
|
||||||
|
-290 -910 -20 -80 -47 -180 -61 -223 -13 -43 -24 -92 -24 -109 0 -42 -43 -79
|
||||||
|
-132 -112 -56 -20 -108 -52 -213 -132 -77 -58 -162 -117 -190 -131 -85 -43
|
||||||
|
-107 -75 -62 -89 12 -3 30 -15 40 -25 10 -11 30 -19 45 -19 29 0 146 52 175
|
||||||
|
77 9 9 19 14 22 12 2 -3 -21 -24 -51 -47 -55 -43 -63 -59 -42 -80 30 -30 130
|
||||||
|
5 198 69 54 52 127 109 139 109 20 0 11 -27 -25 -80 -38 -56 -38 -74 0 -91 33
|
||||||
|
-16 67 7 135 89 31 37 70 71 95 84 l42 20 82 -21 c45 -11 95 -21 111 -21 17 0
|
||||||
|
50 -11 75 -25 58 -32 136 -35 166 -5 35 35 26 57 -40 90 -59 30 -156 132 -186
|
||||||
|
195 -30 63 -31 124 -3 258 43 213 95 336 279 657 126 219 231 423 267 520 14
|
||||||
|
36 40 128 58 205 19 77 50 185 69 240 55 159 182 450 195 447 7 -1 9 7 5 23
|
||||||
|
-10 38 0 30 37 -30 42 -69 60 -53 28 27 -36 92 -39 98 -34 98 3 0 14 -18 25
|
||||||
|
-41 14 -26 26 -39 35 -35 9 3 28 -22 59 -81 65 -121 162 -266 237 -353 35 -41
|
||||||
|
174 -196 309 -345 359 -394 379 -421 409 -549 25 -103 90 -214 169 -287 74
|
||||||
|
-67 203 -135 332 -173 110 -33 472 -112 575 -125 325 -44 688 -30 1453 54 172
|
||||||
|
19 352 35 400 35 112 1 156 11 272 66 139 66 171 103 171 197 0 64 -11 95 -52
|
||||||
|
141 -17 20 -30 38 -28 39 2 1 13 7 24 13 11 6 21 23 23 38 2 14 12 31 23 36
|
||||||
|
12 7 19 21 19 38 0 19 7 30 23 37 14 6 23 21 25 39 2 16 10 36 18 44 10 9 13
|
||||||
|
24 9 41 -4 20 -1 28 16 36 58 26 47 86 -21 106 -38 12 -40 14 -40 51 0 51 -18
|
||||||
|
82 -82 145 -73 70 -132 105 -358 213 -547 260 -919 419 -1210 517 -13 5 -13 6
|
||||||
|
0 10 8 3 22 13 30 22 23 26 363 124 434 125 l60 1 21 -85 c29 -118 59 -175
|
||||||
|
129 -245 118 -117 234 -156 461 -158 171 -1 271 17 445 80 268 96 361 157 602
|
||||||
|
396 93 92 171 159 246 209 155 105 513 381 595 458 131 122 189 224 277 485
|
||||||
|
109 325 149 342 163 70 9 -163 30 -242 143 -531 53 -137 98 -258 101 -270 3
|
||||||
|
-14 -5 -28 -29 -46 -18 -14 -94 -80 -168 -147 -137 -123 -261 -216 -306 -227
|
||||||
|
-17 -4 -46 4 -92 27 -60 29 -80 34 -192 41 -69 4 -144 11 -166 14 -103 15
|
||||||
|
-115 -61 -15 -95 19 -6 46 -11 61 -11 44 0 91 -20 88 -38 -2 -8 -15 -24 -30
|
||||||
|
-35 -22 -17 -30 -18 -42 -7 -21 16 -46 6 -46 -19 0 -25 -29 -35 -110 -35 -57
|
||||||
|
-1 -65 -3 -68 -21 -4 -29 44 -54 120 -62 35 -3 66 -12 71 -19 4 -7 31 -25 59
|
||||||
|
-39 41 -21 60 -24 93 -19 25 3 45 2 49 -4 3 -5 34 -9 69 -7 52 1 72 7 108 32
|
||||||
|
58 40 97 59 135 66 32 6 462 230 516 269 18 12 33 17 35 12 2 -6 30 -62 62
|
||||||
|
-126 l58 -116 -3 -112 c-2 -61 -6 -115 -9 -119 -2 -5 -100 -8 -217 -8 -221 0
|
||||||
|
-452 -23 -868 -88 -85 -13 -225 -33 -310 -45 -189 -26 -314 -52 -440 -92 -203
|
||||||
|
-65 -284 -132 -304 -254 -15 -90 30 -173 137 -251 28 -20 113 -85 187 -142 74
|
||||||
|
-58 171 -129 215 -158 105 -71 324 -181 563 -283 106 -45 194 -86 197 -90 9
|
||||||
|
-14 -260 -265 -361 -337 -100 -71 -130 -102 -188 -193 -16 -24 -53 -73 -82
|
||||||
|
-107 -30 -35 -67 -89 -83 -121 -20 -41 -63 -92 -135 -163 -86 -87 -106 -112
|
||||||
|
-112 -144 -4 -22 -15 -53 -26 -70 -23 -38 -23 -73 -1 -105 39 -56 94 -81 132
|
||||||
|
-60 18 9 21 8 21 -9 0 -33 11 -51 41 -67 20 -10 35 -12 46 -5 13 7 21 3 36
|
||||||
|
-15 11 -14 29 -24 44 -24 15 0 34 -7 44 -16 9 -8 27 -16 40 -16 13 -1 33 -8
|
||||||
|
44 -15 11 -7 29 -13 40 -13 50 0 129 132 140 232 21 203 78 389 136 444 17 16
|
||||||
|
51 56 74 89 89 124 200 212 433 343 l142 81 14 -27 c16 -32 36 -151 36 -220 0
|
||||||
|
-35 6 -54 21 -71 43 -46 143 -68 168 -37 6 8 14 37 18 65 5 46 11 56 47 85 23
|
||||||
|
18 61 44 86 58 91 53 151 145 153 234 0 38 -5 50 -33 79 -19 19 -53 42 -77 51
|
||||||
|
-24 9 -43 19 -43 23 0 3 28 24 62 46 81 52 213 178 298 284 63 79 75 89 148
|
||||||
|
122 l80 37 32 -49 c79 -122 233 -192 370 -170 222 37 395 196 428 396 18 107
|
||||||
|
35 427 30 560 -9 217 -63 344 -223 514 -52 56 -95 106 -95 111 0 5 4 12 10 15
|
||||||
|
55 34 235 523 290 785 10 52 28 118 39 145 10 28 29 103 41 169 27 142 24 271
|
||||||
|
-7 352 -28 72 -115 215 -185 303 -65 82 -118 184 -125 241 -11 82 59 182 93
|
||||||
|
135 9 -12 17 -14 31 -7 10 6 25 7 33 2 8 -4 27 -6 41 -3 28 5 44 45 33 80 -5
|
||||||
|
15 -4 15 4 4 12 -17 17 -6 76 144 39 99 43 100 22 10 -8 -33 -13 -62 -10 -64
|
||||||
|
10 -10 65 154 83 249 6 30 16 80 22 110 19 85 16 216 -5 278 -11 32 -22 50
|
||||||
|
-29 45 -7 -4 -8 0 -3 13 4 10 4 15 0 12 -6 -7 -89 109 -89 124 0 4 -6 13 -14
|
||||||
|
20 -10 10 -12 10 -7 1 14 -24 -10 -13 -40 19 -16 17 -23 27 -15 23 9 -5 12 -4
|
||||||
|
8 2 -11 18 -131 71 -188 82 -50 11 -127 14 -259 12 -25 -1 -57 -7 -72 -15 -17
|
||||||
|
-9 -28 -11 -28 -4 0 6 -9 8 -22 3 -13 -4 -31 -7 -41 -6 -9 0 -15 -4 -12 -9 3
|
||||||
|
-6 0 -7 -8 -4 -20 7 -127 -84 -176 -149 -43 -57 -111 -185 -111 -208 0 -19
|
||||||
|
-55 -135 -69 -143 -6 -4 -11 -12 -11 -18 0 -19 29 13 66 73 19 33 37 59 40 59
|
||||||
|
10 0 -65 -126 -103 -173 -30 -36 -39 -53 -30 -59 9 -6 9 -8 0 -8 -9 0 -10 -7
|
||||||
|
-2 -27 6 -16 10 -29 10 -30 -1 -11 23 -63 29 -63 4 0 20 10 36 22 30 24 26 14
|
||||||
|
-13 -39 -13 -18 -20 -33 -14 -33 19 0 74 65 97 115 13 27 24 43 24 34 0 -25
|
||||||
|
-21 -81 -42 -111 -23 -34 -23 -46 0 -25 18 16 19 14 21 -70 3 -183 25 -289 76
|
||||||
|
-381 26 -46 33 -96 15 -107 -6 -3 -86 -17 -178 -30 -240 -35 -301 -61 -360
|
||||||
|
-152 -62 -96 -73 -147 -83 -378 -9 -214 -20 -312 -32 -285 -20 45 -77 356 -91
|
||||||
|
492 -18 174 -34 243 -72 325 -58 121 -120 163 -243 163 -63 0 -80 3 -85 16
|
||||||
|
-11 29 -6 103 13 196 43 209 51 282 51 479 -1 301 -22 464 -76 571 -32 64
|
||||||
|
-132 168 -191 200 -79 43 -224 72 -303 61z m2438 -421 c18 -14 38 -35 44 -46
|
||||||
|
9 -16 -39 22 -102 82 -11 11 27 -13 58 -36z m142 -188 c17 -52 7 -51 -11 1 -9
|
||||||
|
25 -13 42 -8 40 4 -3 13 -21 19 -41z m-1000 -42 c0 -5 -7 -17 -15 -28 -14 -18
|
||||||
|
-14 -17 -4 9 12 27 19 34 19 19z m1037 -14 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13
|
||||||
|
3 -3 4 -12 1 -19z m10 -40 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1
|
||||||
|
-19z m-53 -327 c-4 -23 -9 -40 -11 -37 -3 3 -2 23 2 46 4 23 9 39 11 37 3 -2
|
||||||
|
2 -23 -2 -46z m-17 -73 c-3 -8 -6 -5 -6 6 -1 11 2 17 5 13 3 -3 4 -12 1 -19z
|
||||||
|
m-3487 -790 c-17 -35 -55 -110 -84 -168 -29 -58 -72 -163 -96 -235 -45 -134
|
||||||
|
-64 -175 -84 -175 -6 1 -23 18 -38 40 -31 44 -71 60 -155 60 -29 0 -53 3 -52
|
||||||
|
8 0 4 63 59 141 122 182 149 293 258 347 343 24 37 45 67 47 67 3 0 -10 -28
|
||||||
|
-26 -62z m-4768 -415 c-37 -46 -160 -176 -140 -148 21 29 160 185 165 185 3 0
|
||||||
|
-9 -17 -25 -37z m38 -52 c-11 -21 -30 -37 -30 -25 0 8 30 44 37 44 2 0 -1 -9
|
||||||
|
-7 -19z m1692 -588 c22 -30 39 -56 36 -58 -5 -5 -107 115 -122 143 -15 28 42
|
||||||
|
-29 86 -85z m-100 -108 c6 -11 -13 3 -42 30 -28 28 -56 59 -62 70 -6 11 13 -2
|
||||||
|
42 -30 28 -27 56 -59 62 -70z m1587 -1 c29 -6 22 -10 -71 -40 -57 -19 -128
|
||||||
|
-41 -158 -49 -58 -15 -288 -41 -296 -33 -2 3 23 19 56 37 45 24 98 40 208 61
|
||||||
|
153 29 208 34 261 24z m-860 -1488 c150 -59 299 -94 495 -114 l68 -7 -42 -27
|
||||||
|
-42 -28 -111 20 c-62 11 -196 28 -300 38 -103 10 -189 21 -192 23 -2 3 -1 21
|
||||||
|
4 40 5 19 12 46 15 62 4 15 9 27 13 27 3 0 45 -15 92 -34z m3893 -371 l37 -6
|
||||||
|
-55 -72 c-31 -40 -59 -72 -62 -73 -4 -1 -51 44 -104 100 l-97 101 122 -22 c67
|
||||||
|
-13 139 -25 159 -28z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
394
packages/frontend/src/lib/components/header/header.svelte
Normal file
394
packages/frontend/src/lib/components/header/header.svelte
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import PeonyIcon from "$lib/components/icon/peony-icon.svelte";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import type { AuthStatus } from "$lib/types";
|
||||||
|
import { logout } from "$lib/services";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { getAssetUrl, isModel } from "$lib/directus";
|
||||||
|
import LogoutButton from "../logout-button/logout-button.svelte";
|
||||||
|
import Separator from "../ui/separator/separator.svelte";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||||
|
import { getUserInitials } from "$lib/utils";
|
||||||
|
import BurgerMenuButton from "../burger-menu-button/burger-menu-button.svelte";
|
||||||
|
import Girls from "../girls/girls.svelte";
|
||||||
|
import Logo from "../logo/logo.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
authStatus: AuthStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { authStatus }: Props = $props();
|
||||||
|
|
||||||
|
let isMobileMenuOpen = $state(false);
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ name: $_("header.home"), href: "/" },
|
||||||
|
{ name: $_("header.models"), href: "/models" },
|
||||||
|
{ name: $_("header.videos"), href: "/videos" },
|
||||||
|
{ name: $_("header.magazine"), href: "/magazine" },
|
||||||
|
{ name: $_("header.about"), href: "/about" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
closeMenu();
|
||||||
|
await logout();
|
||||||
|
goto("/login", { invalidateAll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
isMobileMenuOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveLink(link: any) {
|
||||||
|
return (
|
||||||
|
(page.url.pathname === "/" && link === navLinks[0]) ||
|
||||||
|
(page.url.pathname.startsWith(link.href) && link !== navLinks[0])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-50 w-full bg-gradient-to-br from-card/85 via-card/90 to-card/80 backdrop-blur-xl shadow-2xl shadow-primary/20"
|
||||||
|
>
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<div class="flex items-center justify-evenly h-16">
|
||||||
|
<!-- Logo -->
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="flex w-full items-center gap-3 hover:scale-105 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Logo hideName={true} />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Desktop Navigation -->
|
||||||
|
<nav class="hidden w-full lg:flex items-center justify-center gap-8">
|
||||||
|
{#each navLinks as link}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class={`text-sm hover:text-foreground transition-colors duration-200 font-medium relative group ${
|
||||||
|
isActiveLink(link) ? 'text-foreground' : 'text-foreground/85'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
<span
|
||||||
|
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink(link) ? 'w-full' : 'group-hover:w-full'}`}
|
||||||
|
></span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Desktop Login Button -->
|
||||||
|
{#if authStatus.authenticated}
|
||||||
|
<div class="w-full flex items-center justify-end">
|
||||||
|
<div class="flex items-center gap-2 rounded-full bg-muted/30 p-1">
|
||||||
|
<!-- Notifications -->
|
||||||
|
<!-- <Button variant="ghost" size="sm" class="relative h-9 w-9 rounded-full p-0 hover:bg-background/80">
|
||||||
|
<BellIcon class="h-4 w-4" />
|
||||||
|
<Badge class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground">3</Badge>
|
||||||
|
<span class="sr-only">Notifications</span>
|
||||||
|
</Button> -->
|
||||||
|
|
||||||
|
<!-- <Separator orientation="vertical" class="mx-1 h-6 bg-border/50" /> -->
|
||||||
|
|
||||||
|
<!-- User Actions -->
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="icon"
|
||||||
|
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: '/me' }) ? 'text-foreground' : 'hover:text-foreground'}`}
|
||||||
|
href="/me"
|
||||||
|
title={$_('header.dashboard')}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--dashboard-2-line] h-4 w-4"></span>
|
||||||
|
<span
|
||||||
|
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/me' }) ? 'w-full' : 'group-hover:w-full'}`}
|
||||||
|
></span>
|
||||||
|
<span class="sr-only">{$_('header.dashboard')}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="icon"
|
||||||
|
class={`hidden sm:flex h-9 w-9 rounded-full p-0 relative text-foreground/80 group ${isActiveLink({ href: '/play' }) ? 'text-foreground' : 'hover:text-foreground'}`}
|
||||||
|
href="/play"
|
||||||
|
title={$_('header.play')}
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--rocket-line] h-4 w-4"></span>
|
||||||
|
<span
|
||||||
|
class={`absolute -bottom-1 left-0 w-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-300 ${isActiveLink({ href: '/play' }) ? 'w-full' : 'group-hover:w-full'}`}
|
||||||
|
></span>
|
||||||
|
<span class="sr-only">{$_('header.play')}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" class="hidden md:flex mx-1 h-6 bg-border/50" />
|
||||||
|
|
||||||
|
<!-- Slide Logout Button -->
|
||||||
|
|
||||||
|
<LogoutButton
|
||||||
|
user={{
|
||||||
|
name: authStatus.user!.artist_name,
|
||||||
|
avatar: getAssetUrl(authStatus.user!.avatar?.id, 'mini')!,
|
||||||
|
email: authStatus.user!.email
|
||||||
|
}}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex w-full items-center justify-end gap-4">
|
||||||
|
<Button variant="outline" class="font-medium" href="/login"
|
||||||
|
>{$_('header.login')}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
href="/signup"
|
||||||
|
class="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 font-medium"
|
||||||
|
>{$_('header.signup')}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<BurgerMenuButton
|
||||||
|
label={$_('header.navigation')}
|
||||||
|
bind:isMobileMenuOpen
|
||||||
|
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Mobile Navigation -->
|
||||||
|
<div
|
||||||
|
class={`border-t border-border/20 bg-background/95 bg-gradient-to-br from-primary to-accent backdrop-blur-xl max-h-[calc(100vh-4rem)] overflow-y-auto shadow-xl/30 transition-all duration-250 ${isMobileMenuOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
>
|
||||||
|
{#if isMobileMenuOpen}
|
||||||
|
<div class="container mx-auto grid grid-cols-1 lg:grid-cols-3">
|
||||||
|
<div class="hidden lg:flex col-span-2">
|
||||||
|
<Girls />
|
||||||
|
</div>
|
||||||
|
<div class="py-6 px-4 space-y-6 lg:col-start-3 border-t border-border/20 bg-background/95 ">
|
||||||
|
<!-- User Profile Card -->
|
||||||
|
{#if authStatus.authenticated}
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded-2xl border border-border/50 bg-gradient-to-br from-card to-card/50 p-4 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5"
|
||||||
|
></div>
|
||||||
|
<div class="relative flex items-center gap-4">
|
||||||
|
<Avatar class="h-14 w-14 ring-2 ring-primary/30">
|
||||||
|
<AvatarImage
|
||||||
|
src={getAssetUrl(authStatus.user!.avatar?.id, 'mini')}
|
||||||
|
alt={authStatus.user!.artist_name}
|
||||||
|
/>
|
||||||
|
<AvatarFallback
|
||||||
|
class="bg-gradient-to-br from-primary to-accent text-primary-foreground font-semibold"
|
||||||
|
>
|
||||||
|
{getUserInitials(authStatus.user!.artist_name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
|
<p class="text-base font-semibold text-foreground">
|
||||||
|
{authStatus.user!.artist_name}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{authStatus.user!.email}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<div class="h-2 w-2 rounded-full bg-green-500"></div>
|
||||||
|
<span class="text-xs text-muted-foreground">Online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Notifications Badge -->
|
||||||
|
<!-- <Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="relative h-10 w-10 rounded-full p-0"
|
||||||
|
>
|
||||||
|
<BellIcon class="h-4 w-4" />
|
||||||
|
<Badge
|
||||||
|
class="absolute -right-1 -top-1 h-5 w-5 rounded-full bg-gradient-to-r from-primary to-accent p-0 text-xs text-primary-foreground"
|
||||||
|
>3</Badge
|
||||||
|
>
|
||||||
|
</Button> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Navigation Cards -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3
|
||||||
|
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{$_('header.navigation')}
|
||||||
|
</h3>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
{#each navLinks as link}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class="flex items-center justify-between rounded-xl border border-border/50 bg-card/50 p-4 backdrop-blur-sm transition-all hover:bg-card hover:border-primary/20 {isActiveLink(
|
||||||
|
link
|
||||||
|
)
|
||||||
|
? 'border-primary/30 bg-primary/5'
|
||||||
|
: ''}"
|
||||||
|
onclick={() => (isMobileMenuOpen = false)}
|
||||||
|
>
|
||||||
|
<span class="font-medium text-foreground">{link.name}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- {#if isActiveLink(link)}
|
||||||
|
<div class="h-2 w-2 rounded-full bg-primary"></div>
|
||||||
|
{/if} -->
|
||||||
|
<span
|
||||||
|
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Actions -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3
|
||||||
|
class="px-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{$_('header.account')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
{#if authStatus.authenticated}
|
||||||
|
<a
|
||||||
|
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/me' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||||
|
href="/me"
|
||||||
|
onclick={closeMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--dashboard-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-foreground"
|
||||||
|
>{$_('header.dashboard')}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>{$_('header.dashboard_hint')}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||||
|
></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/play' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||||
|
href="/play"
|
||||||
|
onclick={closeMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--rocket-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-foreground"
|
||||||
|
>{$_('header.play')}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>{$_('header.play_hint')}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||||
|
></span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/login' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||||
|
href="/login"
|
||||||
|
onclick={closeMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--login-circle-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-foreground"
|
||||||
|
>{$_('header.login')}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>{$_('header.login_hint')}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||||
|
></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class={`flex items-center gap-4 rounded-xl border border-border/50 bg-card/50 p-4 text-left backdrop-blur-sm transition-all group hover:bg-card hover:border-primary/20 ${isActiveLink({ href: '/signup' }) ? 'border-primary/30 bg-primary/5' : ''}`}
|
||||||
|
href="/signup"
|
||||||
|
onclick={closeMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={`flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-muted to-muted/50 transition-all group-hover:bg-card group-hover:from-primary/10 group-hover:to-accent/10`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--heart-add-2-line] h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-foreground"
|
||||||
|
>{$_('header.signup')}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>{$_('header.signup_hint')}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--arrow-drop-right-line] h-6 w-6 text-muted-foreground transition-all"
|
||||||
|
></span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if authStatus.authenticated}
|
||||||
|
<!-- Logout Button -->
|
||||||
|
<button
|
||||||
|
class="cursor-pointer flex w-full items-center gap-4 rounded-xl border border-destructive/20 bg-destructive/5 p-4 text-left backdrop-blur-sm transition-all hover:bg-destructive/10 hover:border-destructive/30 group"
|
||||||
|
onclick={handleLogout}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-xl bg-destructive/10 group-hover:bg-destructive/20 transition-all"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon-[ri--logout-circle-r-line] h-4 w-4 text-destructive"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col gap-1">
|
||||||
|
<span class="font-medium text-foreground"
|
||||||
|
>{$_('header.logout')}</span
|
||||||
|
>
|
||||||
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>{$_('header.logout_hint')}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
25
packages/frontend/src/lib/components/icon/peony-icon.svelte
Normal file
25
packages/frontend/src/lib/components/icon/peony-icon.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
size?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className = "", size = "24" }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
class={className}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<g class="" transform="translate(0,0)" style=""
|
||||||
|
><path
|
||||||
|
d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06a310.317 310.317 0 0 0 6.813 18.25c16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186a307.48 307.48 0 0 1-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22a340.211 340.211 0 0 1 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813a290.509 290.509 0 0 0-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844a322.02 322.02 0 0 0-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5a355.064 355.064 0 0 1-1.656-34.218c0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687a312.754 312.754 0 0 0 46-3.97 24.98 24.98 0 0 1-1.532-21.748l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47a23.235 23.235 0 0 1 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75a23.117 23.117 0 0 1-14.845.968c-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063a496.627 496.627 0 0 1-57.406-14.53c2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656a499.132 499.132 0 0 1-48.25 11.656c1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437a23.01 23.01 0 0 1-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z"
|
||||||
|
fill-opacity="1"
|
||||||
|
style="fill: currentColor; stroke: #ce47eb; stroke-width: 10px;"
|
||||||
|
|
||||||
|
></path></g
|
||||||
|
></svg
|
||||||
|
>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import Button from "../ui/button/button.svelte";
|
||||||
|
|
||||||
|
const { images = [] } = $props();
|
||||||
|
|
||||||
|
let isViewerOpen = $state(false);
|
||||||
|
let currentImageIndex = $state(0);
|
||||||
|
let imageLoading = $state(false);
|
||||||
|
|
||||||
|
let currentImage = $derived(images[currentImageIndex]);
|
||||||
|
let canGoPrev = $derived(currentImageIndex > 0);
|
||||||
|
let canGoNext = $derived(currentImageIndex < images.length - 1);
|
||||||
|
|
||||||
|
function openViewer(index) {
|
||||||
|
currentImageIndex = index;
|
||||||
|
isViewerOpen = true;
|
||||||
|
imageLoading = true;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeViewer() {
|
||||||
|
isViewerOpen = false;
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigatePrev() {
|
||||||
|
if (canGoPrev) {
|
||||||
|
currentImageIndex--;
|
||||||
|
imageLoading = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateNext() {
|
||||||
|
if (canGoNext) {
|
||||||
|
currentImageIndex++;
|
||||||
|
imageLoading = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadImage() {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = currentImage.url;
|
||||||
|
link.download = currentImage.title.replace(/\\s+/g, "_") + ".jpg";
|
||||||
|
link.target = "_blank";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (!isViewerOpen) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowLeft":
|
||||||
|
event.preventDefault();
|
||||||
|
navigatePrev();
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
event.preventDefault();
|
||||||
|
navigateNext();
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
event.preventDefault();
|
||||||
|
closeViewer();
|
||||||
|
break;
|
||||||
|
case "d":
|
||||||
|
case "D":
|
||||||
|
event.preventDefault();
|
||||||
|
downloadImage();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageLoad() {
|
||||||
|
imageLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!browser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeydown);
|
||||||
|
// Preload images
|
||||||
|
images.forEach((img) => {
|
||||||
|
const preload = new Image();
|
||||||
|
preload.src = img.url;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (!browser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.removeEventListener("keydown", handleKeydown);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Gallery Grid -->
|
||||||
|
<div class="w-full mx-auto">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 animate-fade-in"
|
||||||
|
>
|
||||||
|
{#each images as image, index}
|
||||||
|
<button
|
||||||
|
onclick={() => openViewer(index)}
|
||||||
|
class="group relative aspect-square overflow-hidden rounded-xl bg-zinc-900 border border-zinc-800 transition-all duration-300 hover:scale-[1.03] hover:border-primary/50 hover:shadow-2xl hover:shadow-primary/20 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-zinc-950"
|
||||||
|
>
|
||||||
|
<!-- Thumbnail Image -->
|
||||||
|
<img
|
||||||
|
src={image.thumbnail}
|
||||||
|
alt={image.title}
|
||||||
|
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Gradient Overlay -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Hover Glow Effect -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-br from-primary/20 to-accent/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Image Info Overlay -->
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-0 right-0 p-4 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300"
|
||||||
|
>
|
||||||
|
<h3 class="text-foreground font-semibold text-sm mb-1">
|
||||||
|
{image.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-zinc-400 text-xs">
|
||||||
|
{index + 1} / {images.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Viewer Modal -->
|
||||||
|
{#if isViewerOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center animate-fade-in"
|
||||||
|
>
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/95 backdrop-blur-xl"
|
||||||
|
onclick={closeViewer}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Viewer Content -->
|
||||||
|
<div class="relative w-[90vw] h-[90vh] flex flex-col animate-slide-up">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="absolute top-0 left-0 right-0 z-20 p-6 rounded-2xl">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="text-3xl font-bold text-foreground mb-2 drop-shadow-lg">
|
||||||
|
{currentImage.title}
|
||||||
|
</h2>
|
||||||
|
<div class="text-primary font-medium mb-3">
|
||||||
|
{$_("image_viewer.index", {
|
||||||
|
values: {
|
||||||
|
index: currentImageIndex + 1,
|
||||||
|
size: images.length
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-400 max-w-2xl">
|
||||||
|
{currentImage.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Buttons -->
|
||||||
|
<div class="flex gap-3 ml-8">
|
||||||
|
<Button
|
||||||
|
onclick={downloadImage}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="w-11 h-11 rounded-lg bg-foreground/10 backdrop-blur border border-foreground/10 text-foreground flex items-center justify-center transition-all hover:bg-primary hover:border-primary hover:scale-105 hover:shadow-lg active:scale-95"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--download-fill] w-4 h-4"></span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onclick={closeViewer}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="w-11 h-11 rounded-lg bg-foreground/10 backdrop-blur border border-foreground/10 text-foreground flex items-center justify-center transition-all hover:bg-destructive hover:border-destructive hover:scale-105 hover:shadow-lg active:scale-95"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--close-fill] w-4 h-4"></span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Container -->
|
||||||
|
<div class="flex-1 flex items-center justify-center relative px-20">
|
||||||
|
<!-- Previous Button -->
|
||||||
|
<Button
|
||||||
|
onclick={navigatePrev}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="absolute left-8 top-1/2 -translate-y-1/2 w-14 h-14 rounded-full bg-foreground/10 backdrop-blur border border-foreground/10 text-foreground flex items-center justify-center transition-all hover:bg-accent hover:border-accent hover:scale-110 hover:shadow-xl active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-foreground/10 disabled:hover:border-foreground/10 disabled:hover:scale-100 disabled:hover:shadow-none z-10"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--arrow-left-s-line] w-5 h-5"></span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Main Image -->
|
||||||
|
<div class="relative max-w-full max-h-full">
|
||||||
|
{#if imageLoading}
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 border-4 border-primary/30 border-t-primary rounded-full animate-spin"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<img
|
||||||
|
src={currentImage.url}
|
||||||
|
alt={currentImage.title}
|
||||||
|
onload={handleImageLoad}
|
||||||
|
class="max-w-full max-h-[calc(90vh-8rem)] object-contain rounded-lg shadow-2xl {imageLoading
|
||||||
|
? 'opacity-0'
|
||||||
|
: 'opacity-100 animate-zoom-in'} transition-opacity duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next Button -->
|
||||||
|
<Button
|
||||||
|
onclick={navigateNext}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="absolute right-8 top-1/2 -translate-y-1/2 w-14 h-14 rounded-full bg-foreground/10 backdrop-blur border border-foreground/10 text-foreground flex items-center justify-center transition-all hover:bg-accent hover:border-accent hover:scale-110 hover:shadow-xl active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-foreground/10 disabled:hover:border-foreground/10 disabled:hover:scale-100 disabled:hover:shadow-none z-10"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--arrow-right-s-line] w-5 h-5"></span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Keyboard Hints -->
|
||||||
|
<div
|
||||||
|
class="hidden md:flex absolute bottom-6 left-1/2 -translate-x-1/2 gap-4 px-6 py-3 bg-zinc-900/95 backdrop-blur-sm rounded-lg border border-zinc-800 text-zinc-400 text-sm"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<kbd
|
||||||
|
class="px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-foreground font-mono text-xs"
|
||||||
|
>←</kbd
|
||||||
|
>
|
||||||
|
{$_("image_viewer.previous")}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<kbd
|
||||||
|
class="px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-foreground font-mono text-xs"
|
||||||
|
>→</kbd
|
||||||
|
>
|
||||||
|
{$_("image_viewer.next")}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<kbd
|
||||||
|
class="px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-foreground font-mono text-xs"
|
||||||
|
>Esc</kbd
|
||||||
|
>
|
||||||
|
{$_("image_viewer.close")}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<kbd
|
||||||
|
class="px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-foreground font-mono text-xs"
|
||||||
|
>D</kbd
|
||||||
|
>
|
||||||
|
{$_("image_viewer.download")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
21
packages/frontend/src/lib/components/logo/logo.svelte
Normal file
21
packages/frontend/src/lib/components/logo/logo.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import PeonyIcon from "../icon/peony-icon.svelte";
|
||||||
|
|
||||||
|
const { hideName = false } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<PeonyIcon class="w-13 h-13 text-black" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class={`logo text-3xl text-foreground opacity-90 tracking-wide font-extrabold drop-shadow-x ${hideName ? "hidden sm:inline-block" : ""}`}
|
||||||
|
>
|
||||||
|
{$_('brand.name')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logo {
|
||||||
|
font-family: 'Dancing Script', cursive;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "$lib/components/ui/avatar";
|
||||||
|
import { getUserInitials } from "$lib/utils";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { user, onLogout }: Props = $props();
|
||||||
|
|
||||||
|
let isDragging = $state(false);
|
||||||
|
let slidePosition = $state(0);
|
||||||
|
let startX = 0;
|
||||||
|
let currentX = 0;
|
||||||
|
let maxSlide = 117; // Maximum slide distance
|
||||||
|
let threshold = 0.75; // 70% threshold to trigger logout
|
||||||
|
|
||||||
|
// Calculate slide progress (0 to 1)
|
||||||
|
const slideProgress = $derived(Math.min(slidePosition / maxSlide, 1));
|
||||||
|
const isNearThreshold = $derived(slideProgress > threshold);
|
||||||
|
|
||||||
|
const handleStart = (clientX: number) => {
|
||||||
|
isDragging = true;
|
||||||
|
startX = clientX;
|
||||||
|
currentX = clientX;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = (clientX: number) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
currentX = clientX;
|
||||||
|
const deltaX = currentX - startX;
|
||||||
|
slidePosition = Math.max(0, Math.min(deltaX, maxSlide));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
isDragging = false;
|
||||||
|
|
||||||
|
if (slideProgress >= threshold) {
|
||||||
|
// Trigger logout
|
||||||
|
slidePosition = maxSlide;
|
||||||
|
onLogout();
|
||||||
|
} else {
|
||||||
|
// Snap back
|
||||||
|
slidePosition = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse events
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleStart(e.clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
handleMove(e.clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
handleEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch events
|
||||||
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
|
handleStart(e.touches[0].clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMove(e.touches[0].clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
handleEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add global event listeners when dragging
|
||||||
|
$effect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
document.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||||
|
document.addEventListener("touchend", handleTouchEnd);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
document.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
document.removeEventListener("touchend", handleTouchEnd);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative h-10 w-40 rounded-full bg-muted/30 overflow-hidden select-none transition-all duration-300 bg-muted/40 shadow-lg shadow-accent/10 {isDragging ? 'cursor-grabbing' : ''}"
|
||||||
|
style="background: linear-gradient(90deg,
|
||||||
|
oklch(var(--primary) / 0.3) 0%,
|
||||||
|
oklch(var(--primary) / 0.3) {(1 - slideProgress) * 100}%,
|
||||||
|
oklch(var(--accent) / {0.1 + slideProgress * 0.2}) {(1 - slideProgress) * 100}%,
|
||||||
|
oklch(var(--accent) / {0.2 + slideProgress * 0.3}) 100%
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<!-- Background slide indicator -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 rounded-full transition-all duration-200"
|
||||||
|
style="background: linear-gradient(90deg,
|
||||||
|
transparent 0%,
|
||||||
|
transparent {Math.max(0, slideProgress * 100 - 20)}%,
|
||||||
|
oklch(var(--accent) / {slideProgress * 0.1}) {slideProgress * 100}%,
|
||||||
|
oklch(var(--accent) / {slideProgress * 0.2}) 100%
|
||||||
|
)"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Sliding user info -->
|
||||||
|
<button class="cursor-grab absolute left-0 top-0 h-full flex items-center gap-3 px-2 transition-all duration-200 ease-out rounded-full bg-background/80 backdrop-blur-sm border border-border/50 bg-background/90 border-primary/20 {isDragging ? '' : 'transition-all duration-300 ease-out'}" style="transform: translateX({slidePosition}px); width: calc(100% - {slidePosition}px);" onmousedown={handleMouseDown} ontouchstart={handleTouchStart}>
|
||||||
|
<Avatar class="h-7 w-7 ring-2 ring-accent/20 transition-all duration-200 {isNearThreshold ? 'ring-destructive/40' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}">
|
||||||
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
|
<AvatarFallback class="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs font-semibold transition-all duration-200 {isNearThreshold ? 'from-destructive to-destructive/80' : ''}">
|
||||||
|
{getUserInitials(user.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div class="text-left flex flex-col min-w-0 flex-1">
|
||||||
|
<span class="text-sm font-medium text-foreground leading-none truncate transition-all duration-200 {isNearThreshold ? 'text-destructive' : ''}" style="opacity: {Math.max(0.15, 1 - slideProgress * 1.5)}">{user.name.split(" ")[0]}</span>
|
||||||
|
<span class="text-xs text-muted-foreground leading-none transition-all duration-200 {isNearThreshold ? 'text-destructive/70' : ''}" style="opacity: {Math.max(0.1, 1 - slideProgress * 1.8)}">
|
||||||
|
{slideProgress > 0.3 ? "Logout" : "Online"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Logout icon area -->
|
||||||
|
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200 {isNearThreshold ? 'bg-destructive text-destructive-foreground scale-110' : 'bg-transparent text-foreground'}">
|
||||||
|
<span class="icon-[ri--logout-circle-r-line] h-4 w-4 transition-transform duration-200 {isNearThreshold ? 'scale-110' : ''}" ></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Progress indicator -->
|
||||||
|
<!-- <div class="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-primary to-accent transition-all duration-200 rounded-full" style="width: {slideProgress * 100}%"></div> -->
|
||||||
|
</div>
|
||||||
24
packages/frontend/src/lib/components/meta/meta.svelte
Normal file
24
packages/frontend/src/lib/components/meta/meta.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { PUBLIC_URL || http://localhost:3000 } from "$env/static/public";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image = `${PUBLIC_URL || http://localhost:3000}/img/kamasutra.jpg`,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$_("head.title", { values: { title } })}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<meta property="og:title" content={$_("head.title", { values: { title } })} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:image" content={image} />
|
||||||
|
</svelte:head>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "$lib/components/ui/dialog";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Separator } from "$lib/components/ui/separator";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import Label from "../ui/label/label.svelte";
|
||||||
|
import Input from "../ui/input/input.svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
email: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
|
||||||
|
async function handleSubscription(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
await fetch("/newsletter", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
$_("newsletter_signup.toast_subscribe", { values: { email } }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open = $bindable(), email = $bindable() }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open>
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center shrink-0 grow-0"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--newspaper-line]"></span>
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<DialogTitle
|
||||||
|
class="text-left text-xl font-semibold text-primary-foreground"
|
||||||
|
>{$_('newsletter_signup.title')}</DialogTitle
|
||||||
|
>
|
||||||
|
<DialogDescription class="text-left text-sm">
|
||||||
|
{$_('newsletter_signup.description')}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Separator class="my-4" />
|
||||||
|
|
||||||
|
<form onsubmit={handleSubscription}>
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="space-y-2 flex gap-4 items-center">
|
||||||
|
<Label for="email" class="m-0">{$_('newsletter_signup.email')}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder={$_('newsletter_signup.email_placeholder')}
|
||||||
|
bind:value={email}
|
||||||
|
required
|
||||||
|
class="bg-background/50 border-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator class="my-8" />
|
||||||
|
|
||||||
|
<!-- Close Button -->
|
||||||
|
<div class="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (open = false)}
|
||||||
|
class="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--close-large-line]"></span>
|
||||||
|
{$_('newsletter_signup.close')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
class="cursor-pointer"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{#if isLoading}
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"
|
||||||
|
></div>
|
||||||
|
{$_('newsletter_signup.subscribing')}
|
||||||
|
{:else}
|
||||||
|
<span class="icon-[ri--check-line]"></span>
|
||||||
|
{$_('newsletter_signup.subscribe')}
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script>
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Card, CardContent } from "../ui/card";
|
||||||
|
import NewsletterSignupPopup from "./newsletter-signup-popup.svelte";
|
||||||
|
let isPopupOpen = $state(false);
|
||||||
|
|
||||||
|
let { email = "" } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Newsletter Signup -->
|
||||||
|
<Card class="p-0 not-last:bg-gradient-to-br from-primary/10 to-accent/10">
|
||||||
|
<CardContent class="p-6 text-center">
|
||||||
|
<h3 class="font-semibold mb-2">{$_('newsletter_signup.title')}</h3>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
{$_('newsletter_signup.description')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onclick={() => (isPopupOpen = true)}
|
||||||
|
target="_blank"
|
||||||
|
class="cursor-pointer w-full bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
|
>{$_('newsletter_signup.cta')}</Button
|
||||||
|
>
|
||||||
|
<NewsletterSignupPopup bind:open={isPopupOpen} {email} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onclick: () => void;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onclick, icon, label }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
{onclick}
|
||||||
|
aria-label={label}
|
||||||
|
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center hover:bg-primary/20 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span class={icon + " w-4 h-4 text-primary"}></span>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import ShareButton from "./share-button.svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import type { ShareContent } from "$lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: ShareContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { content }: Props = $props();
|
||||||
|
|
||||||
|
// Share handlers
|
||||||
|
const shareToX = () => {
|
||||||
|
const text = `${content.title} - ${content.description}`;
|
||||||
|
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(content.url)}`;
|
||||||
|
window.open(url, "_blank", "width=600,height=400");
|
||||||
|
toast.success($_("sharing_popup.success.x"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareToFacebook = () => {
|
||||||
|
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(content.url)}"e=${encodeURIComponent(content.title)}`;
|
||||||
|
window.open(url, "_blank", "width=600,height=400");
|
||||||
|
toast.success($_("sharing_popup.success.facebook"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareViaEmail = () => {
|
||||||
|
const subject = encodeURIComponent(content.title);
|
||||||
|
const body = encodeURIComponent(`${content.description}\n\n${content.url}`);
|
||||||
|
const url = `mailto:?subject=${subject}&body=${body}`;
|
||||||
|
window.location.href = url;
|
||||||
|
toast.success($_("sharing_popup.success.email"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareToWhatsApp = () => {
|
||||||
|
const text = `${content.title}\n\n${content.description}\n\n${content.url}`;
|
||||||
|
const url = `https://wa.me/?text=${encodeURIComponent(text)}`;
|
||||||
|
window.open(url, "_blank");
|
||||||
|
toast.success($_("sharing_popup.success.whatsapp"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareToTelegram = () => {
|
||||||
|
const text = `${content.title}\n\n${content.description}`;
|
||||||
|
const url = `https://t.me/share/url?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(text)}`;
|
||||||
|
window.open(url, "_blank");
|
||||||
|
toast.success($_("sharing_popup.success.telegram"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content.url);
|
||||||
|
toast.success($_("sharing_popup.success.copy"));
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = content.url;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
toast.success($_("sharing_popup.success.copy"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="text-center space-y-4">
|
||||||
|
<h4 class="text-sm font-medium text-muted-foreground">
|
||||||
|
{$_("sharing_popup.subtitle")}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-3 flex-wrap">
|
||||||
|
<ShareButton
|
||||||
|
onclick={shareToX}
|
||||||
|
icon="icon-[ri--twitter-x-line]"
|
||||||
|
label={$_("sharing_popup.share.x")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShareButton
|
||||||
|
onclick={shareToFacebook}
|
||||||
|
icon="icon-[ri--facebook-line]"
|
||||||
|
label={$_("sharing_popup.share.facebook")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShareButton
|
||||||
|
onclick={shareViaEmail}
|
||||||
|
icon="icon-[ri--mail-line]"
|
||||||
|
label={$_("sharing_popup.share.email")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShareButton
|
||||||
|
onclick={shareToWhatsApp}
|
||||||
|
icon="icon-[ri--whatsapp-line]"
|
||||||
|
label={$_("sharing_popup.share.whatsapp")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShareButton
|
||||||
|
onclick={shareToTelegram}
|
||||||
|
icon="icon-[ri--telegram-2-line]"
|
||||||
|
label={$_("sharing_popup.share.telegram")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShareButton
|
||||||
|
onclick={copyLink}
|
||||||
|
icon="icon-[ri--file-copy-line]"
|
||||||
|
label={$_("sharing_popup.share.copy")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script>
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import SharingPopup from "./sharing-popup.svelte";
|
||||||
|
import Button from "../ui/button/button.svelte";
|
||||||
|
|
||||||
|
const { content } = $props();
|
||||||
|
let isPopupOpen = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onclick={() => (isPopupOpen = true)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="flex items-center gap-2 border-primary/20 hover:bg-primary/10 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--share-2-line] w-4 h-4"></span>
|
||||||
|
{$_('sharing_popup_button.share')}
|
||||||
|
</Button>
|
||||||
|
<SharingPopup bind:open={isPopupOpen} {content} />
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "$lib/components/ui/dialog";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Separator } from "$lib/components/ui/separator";
|
||||||
|
import ShareServices from "./share-services.svelte";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface ShareContent {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
type: "video" | "model" | "article" | "link";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
content: ShareContent;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open = $bindable(), content }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open>
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center shrink-0 grow-0"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--share-2-line] text-primary-foreground"></span>
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<DialogTitle class="text-left text-xl font-semibold text-primary-foreground"
|
||||||
|
>{$_("sharing_popup.title")}</DialogTitle
|
||||||
|
>
|
||||||
|
<DialogDescription class="text-left text-sm">
|
||||||
|
{$_("sharing_popup.description", {
|
||||||
|
values: { type: content.type },
|
||||||
|
})}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Preview -->
|
||||||
|
<div class="text-left bg-muted/60 rounded-lg p-4 space-y-2">
|
||||||
|
<h4 class="font-medium text-sm text-primary-foreground">
|
||||||
|
{content.title}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-muted-foreground">{content.description}</p>
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<span class="px-2 py-1 bg-primary/10 text-primary rounded-full capitalize">
|
||||||
|
{content.type}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground text-clip">{content.url}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Separator class="my-4" />
|
||||||
|
|
||||||
|
<!-- Share Services -->
|
||||||
|
<ShareServices {content} />
|
||||||
|
|
||||||
|
<Separator class="my-4" />
|
||||||
|
|
||||||
|
<!-- Close Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (open = false)}
|
||||||
|
class="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
<span class="icon-[ri--close-large-line]"></span>
|
||||||
|
{$_("sharing_popup.close")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert-description"
|
||||||
|
class={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert-title"
|
||||||
|
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
44
packages/frontend/src/lib/components/ui/alert/alert.svelte
Normal file
44
packages/frontend/src/lib/components/ui/alert/alert.svelte
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const alertVariants = tv({
|
||||||
|
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
variant?: AlertVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="alert"
|
||||||
|
class={cn(alertVariants({ variant }), className)}
|
||||||
|
{...restProps}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
14
packages/frontend/src/lib/components/ui/alert/index.ts
Normal file
14
packages/frontend/src/lib/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Root from "./alert.svelte";
|
||||||
|
import Description from "./alert-description.svelte";
|
||||||
|
import Title from "./alert-title.svelte";
|
||||||
|
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Description,
|
||||||
|
Title,
|
||||||
|
//
|
||||||
|
Root as Alert,
|
||||||
|
Description as AlertDescription,
|
||||||
|
Title as AlertTitle,
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.FallbackProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
bind:ref
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.ImageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
bind:ref
|
||||||
|
data-slot="avatar-image"
|
||||||
|
class={cn("aspect-square size-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
19
packages/frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
19
packages/frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
loadingStatus = $bindable("loading"),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
bind:loadingStatus
|
||||||
|
data-slot="avatar"
|
||||||
|
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
13
packages/frontend/src/lib/components/ui/avatar/index.ts
Normal file
13
packages/frontend/src/lib/components/ui/avatar/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Root from "./avatar.svelte";
|
||||||
|
import Image from "./avatar-image.svelte";
|
||||||
|
import Fallback from "./avatar-fallback.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Image,
|
||||||
|
Fallback,
|
||||||
|
//
|
||||||
|
Root as Avatar,
|
||||||
|
Image as AvatarImage,
|
||||||
|
Fallback as AvatarFallback,
|
||||||
|
};
|
||||||
86
packages/frontend/src/lib/components/ui/button/button.svelte
Normal file
86
packages/frontend/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type {
|
||||||
|
HTMLAnchorAttributes,
|
||||||
|
HTMLButtonAttributes,
|
||||||
|
} from "svelte/elements";
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const buttonVariants = tv({
|
||||||
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||||
|
|
||||||
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
ref = $bindable(null),
|
||||||
|
href = undefined,
|
||||||
|
type = "button",
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
href={disabled ? undefined : href}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
role={disabled ? "link" : undefined}
|
||||||
|
tabindex={disabled ? -1 : undefined}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
17
packages/frontend/src/lib/components/ui/button/index.ts
Normal file
17
packages/frontend/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Root, {
|
||||||
|
type ButtonProps,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
buttonVariants,
|
||||||
|
} from "./button.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
type ButtonProps as Props,
|
||||||
|
//
|
||||||
|
Root as Button,
|
||||||
|
buttonVariants,
|
||||||
|
type ButtonProps,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-action"
|
||||||
|
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-footer"
|
||||||
|
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-header"
|
||||||
|
class={cn(
|
||||||
|
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-title"
|
||||||
|
class={cn("font-semibold leading-none", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
23
packages/frontend/src/lib/components/ui/card/card.svelte
Normal file
23
packages/frontend/src/lib/components/ui/card/card.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card"
|
||||||
|
class={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
25
packages/frontend/src/lib/components/ui/card/index.ts
Normal file
25
packages/frontend/src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Root from "./card.svelte";
|
||||||
|
import Content from "./card-content.svelte";
|
||||||
|
import Description from "./card-description.svelte";
|
||||||
|
import Footer from "./card-footer.svelte";
|
||||||
|
import Header from "./card-header.svelte";
|
||||||
|
import Title from "./card-title.svelte";
|
||||||
|
import Action from "./card-action.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Title,
|
||||||
|
Action,
|
||||||
|
//
|
||||||
|
Root as Card,
|
||||||
|
Content as CardContent,
|
||||||
|
Description as CardDescription,
|
||||||
|
Footer as CardFooter,
|
||||||
|
Header as CardHeader,
|
||||||
|
Title as CardTitle,
|
||||||
|
Action as CardAction,
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
||||||
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
|
import MinusIcon from "@lucide/svelte/icons/minus";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
checked = $bindable(false),
|
||||||
|
indeterminate = $bindable(false),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="checkbox"
|
||||||
|
class={cn(
|
||||||
|
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
bind:checked
|
||||||
|
bind:indeterminate
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ checked, indeterminate })}
|
||||||
|
<div data-slot="checkbox-indicator" class="text-current transition-none">
|
||||||
|
{#if checked}
|
||||||
|
<CheckIcon class="size-3.5" />
|
||||||
|
{:else if indeterminate}
|
||||||
|
<MinusIcon class="size-3.5" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Root from "./checkbox.svelte";
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Checkbox,
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps =
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import XIcon from "@lucide/svelte/icons/x";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import * as Dialog from "./index.js";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
|
portalProps?: DialogPrimitive.PortalProps;
|
||||||
|
children: Snippet;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Portal {...portalProps}>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if showCloseButton}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-header"
|
||||||
|
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
class={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-title"
|
||||||
|
class={cn("text-lg font-semibold leading-none", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps =
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||||
37
packages/frontend/src/lib/components/ui/dialog/index.ts
Normal file
37
packages/frontend/src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import Title from "./dialog-title.svelte";
|
||||||
|
import Footer from "./dialog-footer.svelte";
|
||||||
|
import Header from "./dialog-header.svelte";
|
||||||
|
import Overlay from "./dialog-overlay.svelte";
|
||||||
|
import Content from "./dialog-content.svelte";
|
||||||
|
import Description from "./dialog-description.svelte";
|
||||||
|
import Trigger from "./dialog-trigger.svelte";
|
||||||
|
import Close from "./dialog-close.svelte";
|
||||||
|
|
||||||
|
const Root = DialogPrimitive.Root;
|
||||||
|
const Portal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Title,
|
||||||
|
Portal,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Trigger,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Dialog,
|
||||||
|
Title as DialogTitle,
|
||||||
|
Portal as DialogPortal,
|
||||||
|
Footer as DialogFooter,
|
||||||
|
Header as DialogHeader,
|
||||||
|
Trigger as DialogTrigger,
|
||||||
|
Overlay as DialogOverlay,
|
||||||
|
Content as DialogContent,
|
||||||
|
Description as DialogDescription,
|
||||||
|
Close as DialogClose,
|
||||||
|
};
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<!--
|
||||||
|
Installed from @ieedan/shadcn-svelte-extras
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils/utils";
|
||||||
|
import UploadIcon from "@lucide/svelte/icons/upload";
|
||||||
|
import { displaySize } from ".";
|
||||||
|
import { useId } from "bits-ui";
|
||||||
|
import type { FileDropZoneProps, FileRejectedReason } from "./types";
|
||||||
|
|
||||||
|
let {
|
||||||
|
id = useId(),
|
||||||
|
children,
|
||||||
|
maxFiles,
|
||||||
|
maxFileSize,
|
||||||
|
fileCount,
|
||||||
|
disabled = false,
|
||||||
|
onUpload,
|
||||||
|
onFileRejected,
|
||||||
|
accept,
|
||||||
|
class: className,
|
||||||
|
...rest
|
||||||
|
}: FileDropZoneProps = $props();
|
||||||
|
|
||||||
|
if (maxFiles !== undefined && fileCount === undefined) {
|
||||||
|
console.warn(
|
||||||
|
"Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploading = $state(false);
|
||||||
|
|
||||||
|
const drop = async (
|
||||||
|
e: DragEvent & {
|
||||||
|
currentTarget: EventTarget & HTMLLabelElement;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (disabled || !canUploadFiles) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
|
||||||
|
|
||||||
|
await upload(droppedFiles);
|
||||||
|
};
|
||||||
|
|
||||||
|
const change = async (
|
||||||
|
e: Event & {
|
||||||
|
currentTarget: EventTarget & HTMLInputElement;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
const selectedFiles = e.currentTarget.files;
|
||||||
|
|
||||||
|
if (!selectedFiles) return;
|
||||||
|
|
||||||
|
await upload(Array.from(selectedFiles));
|
||||||
|
|
||||||
|
// this if a file fails and we upload the same file again we still get feedback
|
||||||
|
(e.target as HTMLInputElement).value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldAcceptFile = (
|
||||||
|
file: File,
|
||||||
|
fileNumber: number,
|
||||||
|
): FileRejectedReason | undefined => {
|
||||||
|
if (maxFileSize !== undefined && file.size > maxFileSize)
|
||||||
|
return "Maximum file size exceeded";
|
||||||
|
|
||||||
|
if (maxFiles !== undefined && fileNumber > maxFiles)
|
||||||
|
return "Maximum files uploaded";
|
||||||
|
|
||||||
|
if (!accept) return undefined;
|
||||||
|
|
||||||
|
const acceptedTypes = accept.split(",").map((a) => a.trim().toLowerCase());
|
||||||
|
const fileType = file.type.toLowerCase();
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
|
||||||
|
const isAcceptable = acceptedTypes.some((pattern) => {
|
||||||
|
// check extension like .mp4
|
||||||
|
if (fileType.startsWith(".")) {
|
||||||
|
return fileName.endsWith(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if pattern has wild card like video/*
|
||||||
|
if (pattern.endsWith("/*")) {
|
||||||
|
const baseType = pattern.slice(0, pattern.indexOf("/*"));
|
||||||
|
return fileType.startsWith(baseType + "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise it must be a specific type like video/mp4
|
||||||
|
return fileType === pattern;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAcceptable) return "File type not allowed";
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const upload = async (uploadFiles: File[]) => {
|
||||||
|
uploading = true;
|
||||||
|
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < uploadFiles.length; i++) {
|
||||||
|
const file = uploadFiles[i];
|
||||||
|
|
||||||
|
const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1);
|
||||||
|
|
||||||
|
if (rejectedReason) {
|
||||||
|
onFileRejected?.({ file, reason: rejectedReason });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
await onUpload(validFiles);
|
||||||
|
|
||||||
|
uploading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canUploadFiles = $derived(
|
||||||
|
!disabled &&
|
||||||
|
!uploading &&
|
||||||
|
!(
|
||||||
|
maxFiles !== undefined &&
|
||||||
|
fileCount !== undefined &&
|
||||||
|
fileCount >= maxFiles
|
||||||
|
),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
ondragover={(e) => e.preventDefault()}
|
||||||
|
ondrop={drop}
|
||||||
|
for={id}
|
||||||
|
aria-disabled={!canUploadFiles}
|
||||||
|
class={cn(
|
||||||
|
"border-border hover:bg-accent/25 flex h-48 w-full place-items-center justify-center rounded-lg border-2 border-dashed p-6 transition-all hover:cursor-pointer aria-disabled:opacity-50 aria-disabled:hover:cursor-not-allowed",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col place-items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
class="border-border text-muted-foreground flex size-14 place-items-center justify-center rounded-full border border-dashed"
|
||||||
|
>
|
||||||
|
<UploadIcon class="size-7" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5 text-center">
|
||||||
|
<span class="text-muted-foreground font-medium">
|
||||||
|
Drag 'n' drop files here, or click to select files
|
||||||
|
</span>
|
||||||
|
{#if maxFiles || maxFileSize}
|
||||||
|
<span class="text-muted-foreground/75 text-sm">
|
||||||
|
{#if maxFiles}
|
||||||
|
<span>You can upload {maxFiles} files</span>
|
||||||
|
{/if}
|
||||||
|
{#if maxFiles && maxFileSize}
|
||||||
|
<span>(up to {displaySize(maxFileSize)} each)</span>
|
||||||
|
{/if}
|
||||||
|
{#if maxFileSize && !maxFiles}
|
||||||
|
<span>Maximum size {displaySize(maxFileSize)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<input
|
||||||
|
{...rest}
|
||||||
|
disabled={!canUploadFiles}
|
||||||
|
{id}
|
||||||
|
{accept}
|
||||||
|
multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
|
||||||
|
type="file"
|
||||||
|
onchange={change}
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
Installed from @ieedan/shadcn-svelte-extras
|
||||||
|
*/
|
||||||
|
|
||||||
|
import FileDropZone from "./file-drop-zone.svelte";
|
||||||
|
import { type FileRejectedReason, type FileDropZoneProps } from "./types";
|
||||||
|
|
||||||
|
export const displaySize = (bytes: number): string => {
|
||||||
|
if (bytes < KILOBYTE) return `${bytes.toFixed(0)} B`;
|
||||||
|
|
||||||
|
if (bytes < MEGABYTE) return `${(bytes / KILOBYTE).toFixed(0)} KB`;
|
||||||
|
|
||||||
|
if (bytes < GIGABYTE) return `${(bytes / MEGABYTE).toFixed(0)} MB`;
|
||||||
|
|
||||||
|
return `${(bytes / GIGABYTE).toFixed(0)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utilities for working with file sizes
|
||||||
|
export const BYTE = 1;
|
||||||
|
export const KILOBYTE = 1024;
|
||||||
|
export const MEGABYTE = 1024 * KILOBYTE;
|
||||||
|
export const GIGABYTE = 1024 * MEGABYTE;
|
||||||
|
|
||||||
|
// utilities for limiting accepted files
|
||||||
|
export const ACCEPT_IMAGE = "image/*";
|
||||||
|
export const ACCEPT_VIDEO = "video/*";
|
||||||
|
export const ACCEPT_AUDIO = "audio/*";
|
||||||
|
|
||||||
|
export { FileDropZone, type FileRejectedReason, type FileDropZoneProps };
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
Installed from @ieedan/shadcn-svelte-extras
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { WithChildren } from "bits-ui";
|
||||||
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
export type FileRejectedReason =
|
||||||
|
| "Maximum file size exceeded"
|
||||||
|
| "File type not allowed"
|
||||||
|
| "Maximum files uploaded";
|
||||||
|
|
||||||
|
export type FileDropZonePropsWithoutHTML = WithChildren<{
|
||||||
|
ref?: HTMLInputElement | null;
|
||||||
|
/** Called with the uploaded files when the user drops or clicks and selects their files.
|
||||||
|
*
|
||||||
|
* @param files
|
||||||
|
*/
|
||||||
|
onUpload: (files: File[]) => Promise<void>;
|
||||||
|
/** The maximum amount files allowed to be uploaded */
|
||||||
|
maxFiles?: number;
|
||||||
|
fileCount?: number;
|
||||||
|
/** The maximum size of a file in bytes */
|
||||||
|
maxFileSize?: number;
|
||||||
|
/** Called when a file does not meet the upload criteria (size, or type) */
|
||||||
|
onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
|
||||||
|
|
||||||
|
// just for extra documentation
|
||||||
|
/** Takes a comma separated list of one or more file types.
|
||||||
|
*
|
||||||
|
* [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept)
|
||||||
|
*
|
||||||
|
* ### Usage
|
||||||
|
* ```svelte
|
||||||
|
* <FileDropZone
|
||||||
|
* accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ### Common Values
|
||||||
|
* ```svelte
|
||||||
|
* <FileDropZone accept="audio/*"/>
|
||||||
|
* <FileDropZone accept="image/*"/>
|
||||||
|
* <FileDropZone accept="video/*"/>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
accept?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type FileDropZoneProps = FileDropZonePropsWithoutHTML &
|
||||||
|
Omit<HTMLInputAttributes, "multiple" | "files">;
|
||||||
7
packages/frontend/src/lib/components/ui/input/index.ts
Normal file
7
packages/frontend/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./input.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Input,
|
||||||
|
};
|
||||||
57
packages/frontend/src/lib/components/ui/input/input.svelte
Normal file
57
packages/frontend/src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {
|
||||||
|
HTMLInputAttributes,
|
||||||
|
HTMLInputTypeAttribute,
|
||||||
|
} from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||||
|
|
||||||
|
type Props = WithElementRef<
|
||||||
|
Omit<HTMLInputAttributes, "type"> &
|
||||||
|
(
|
||||||
|
| { type: "file"; files?: FileList }
|
||||||
|
| { type?: InputType; files?: undefined }
|
||||||
|
)
|
||||||
|
>;
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
type,
|
||||||
|
files = $bindable(),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if type === "file"}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="input"
|
||||||
|
class={cn(
|
||||||
|
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
type="file"
|
||||||
|
bind:files
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="input"
|
||||||
|
class={cn(
|
||||||
|
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{type}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
7
packages/frontend/src/lib/components/ui/label/index.ts
Normal file
7
packages/frontend/src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./label.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Label,
|
||||||
|
};
|
||||||
20
packages/frontend/src/lib/components/ui/label/label.svelte
Normal file
20
packages/frontend/src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label as LabelPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: LabelPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="label"
|
||||||
|
class={cn(
|
||||||
|
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
37
packages/frontend/src/lib/components/ui/select/index.ts
Normal file
37
packages/frontend/src/lib/components/ui/select/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import Group from "./select-group.svelte";
|
||||||
|
import Label from "./select-label.svelte";
|
||||||
|
import Item from "./select-item.svelte";
|
||||||
|
import Content from "./select-content.svelte";
|
||||||
|
import Trigger from "./select-trigger.svelte";
|
||||||
|
import Separator from "./select-separator.svelte";
|
||||||
|
import ScrollDownButton from "./select-scroll-down-button.svelte";
|
||||||
|
import ScrollUpButton from "./select-scroll-up-button.svelte";
|
||||||
|
import GroupHeading from "./select-group-heading.svelte";
|
||||||
|
|
||||||
|
const Root = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Group,
|
||||||
|
Label,
|
||||||
|
Item,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Separator,
|
||||||
|
ScrollDownButton,
|
||||||
|
ScrollUpButton,
|
||||||
|
GroupHeading,
|
||||||
|
//
|
||||||
|
Root as Select,
|
||||||
|
Group as SelectGroup,
|
||||||
|
Label as SelectLabel,
|
||||||
|
Item as SelectItem,
|
||||||
|
Content as SelectContent,
|
||||||
|
Trigger as SelectTrigger,
|
||||||
|
Separator as SelectSeparator,
|
||||||
|
ScrollDownButton as SelectScrollDownButton,
|
||||||
|
ScrollUpButton as SelectScrollUpButton,
|
||||||
|
GroupHeading as SelectGroupHeading,
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
|
||||||
|
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 4,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||||
|
portalProps?: SelectPrimitive.PortalProps;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Portal {...portalProps}>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
{sideOffset}
|
||||||
|
data-slot="select-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
class={cn(
|
||||||
|
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user