A new start

This commit is contained in:
Valknar XXX
2025-10-25 22:04:41 +02:00
commit be0fc11a5c
193 changed files with 25076 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
target/
pkg/
.env.*

8
README.md Normal file
View 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
View 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"
]
}
}

View 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"
}
}

View 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,
});
});
},
};

View 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;
},
);
});

View 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",
},
},
},
});

View File

View 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"]
}

View 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

File diff suppressed because it is too large Load Diff

View 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"
]

View 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"
}
}

View File

@@ -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);
};
}

View File

@@ -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);
}
}

View 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");
}
}

View File

@@ -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;
}

View 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);
};
}

View 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;
}

View 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);
}
}

View 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);
}
}
}

View 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;
}

View 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
View File

@@ -0,0 +1,4 @@
declare module "*.json" {
const content: string;
export default content;
}

View 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));
};
}

View 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");
}

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -0,0 +1,3 @@
export function getRandomInt(max: number) {
return Math.floor(Math.random() * Math.floor(max));
}

View File

@@ -0,0 +1,6 @@
mod webbluetooth_hardware;
mod webbluetooth_manager;
// pub use webbluetooth_hardware::{WebBluetoothHardwareConnector, WebBluetoothHardware};
pub use webbluetooth_manager::{WebBluetoothCommunicationManagerBuilder};

View 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(())
})
}
}

View 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(())))
}
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"outDir": "dist",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"include": ["src"]
}

View 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",
},
});

View 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
View 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
View File

@@ -0,0 +1,3 @@
node_modules/
.svelte-kit/
build/

View 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"
}

View 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"
}
}

View 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"
}
}

View 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
View 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 {};

View 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>

View 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";
},
});
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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
>

View File

@@ -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}

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)}&quote=${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>

View File

@@ -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} />

View File

@@ -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>

View 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="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>

View File

@@ -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>

View 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>

View 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,
};

View File

@@ -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}
/>

View File

@@ -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}
/>

View 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}
/>

View 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,
};

View 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}

View 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,
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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,
};

View File

@@ -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>

View File

@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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}
/>

View File

@@ -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} />

View 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,
};

View File

@@ -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>

View File

@@ -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 };

View File

@@ -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">;

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View 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}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View 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}
/>

View 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,
};

View File

@@ -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