mirror of
https://github.com/PreMiD/PreMiD.git
synced 2026-04-06 04:41:58 +02:00
Compare commits
5 Commits
api-master
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c44bc69d9f | ||
|
|
7d93ee3a7d | ||
|
|
79e1984940 | ||
|
|
e6526a3666 | ||
|
|
f9a976ef1d |
@@ -1 +0,0 @@
|
||||
FROM mcr.microsoft.com/devcontainers/base:bullseye
|
||||
@@ -1,24 +0,0 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
|
||||
{
|
||||
"name": "PreMiD",
|
||||
"dockerComposeFile": ["docker-compose.yml"],
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspaces",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "lts",
|
||||
"nvmVersion": "latest"
|
||||
},
|
||||
"ghcr.io/joshuanianji/devcontainer-features/mount-pnpm-store:1": {},
|
||||
"ghcr.io/dhoeric/features/act:1": {}
|
||||
},
|
||||
"overrideFeatureInstallOrder": ["ghcr.io/devcontainers/features/node:1", "ghcr.io/joshuanianji/devcontainer-features/mount-pnpm-store:1"],
|
||||
"postCreateCommand": "pnpm i --frozen-lockfile",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["Gruntfuggly.todo-tree", "YoavBls.pretty-ts-errors", "EditorConfig.EditorConfig", "DeepScan.vscode-deepscan", "esbenp.prettier-vscode"]
|
||||
}
|
||||
},
|
||||
"shutdownAction": "stopCompose"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
# Update this to the name of the service you want to work with in your docker-compose.yml file
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# Uncomment if you want to override the service's Dockerfile to one in the .devcontainer
|
||||
# folder. Note that the path of the Dockerfile and context is relative to the *primary*
|
||||
# docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile"
|
||||
# array). The sample below assumes your primary file is in the root of your project.
|
||||
#
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: .devcontainer/Dockerfile
|
||||
|
||||
volumes:
|
||||
# Update this to wherever you want VS Code to mount the folder of your project
|
||||
- ..:/workspaces:cached
|
||||
|
||||
# Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust.
|
||||
# cap_add:
|
||||
# - SYS_PTRACE
|
||||
# security_opt:
|
||||
# - seccomp:unconfined
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: /bin/sh -c "while sleep 1000; do :; done"
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
5
.github/workflows/cd.yaml
vendored
5
.github/workflows/cd.yaml
vendored
@@ -2,7 +2,7 @@ name: CD
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- monorepo
|
||||
- main
|
||||
tags:
|
||||
- "*"
|
||||
permissions:
|
||||
@@ -17,9 +17,6 @@ jobs:
|
||||
target:
|
||||
- pd
|
||||
- schema-server
|
||||
- website
|
||||
- api-worker
|
||||
- api-master
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
5
.github/workflows/ci.yaml
vendored
5
.github/workflows/ci.yaml
vendored
@@ -2,7 +2,7 @@ name: Build, Lint and Test
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- monorepo
|
||||
- main
|
||||
pull_request:
|
||||
jobs:
|
||||
build:
|
||||
@@ -45,9 +45,6 @@ jobs:
|
||||
target:
|
||||
- pd
|
||||
- schema-server
|
||||
- api-worker
|
||||
- api-master
|
||||
- website
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -3,6 +3,7 @@ out
|
||||
dist
|
||||
tmp
|
||||
lib
|
||||
data
|
||||
|
||||
.vscode
|
||||
.env
|
||||
@@ -19,7 +20,9 @@ src/update.ini
|
||||
*.app
|
||||
*.xml.backup
|
||||
*.js
|
||||
!eslint.config.js
|
||||
!*.config.js
|
||||
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -1,2 +1,3 @@
|
||||
*.js
|
||||
*.ts
|
||||
*.ts
|
||||
*.json
|
||||
45
README.md
45
README.md
@@ -1,35 +1,44 @@
|
||||
<img src="https://cdn.rcd.gg/PreMiD.png" width="150px" />
|
||||
<img width="1280" height="800" alt="Chrome Global Screenshots" src="https://github.com/user-attachments/assets/1ea21f91-7499-43de-8b9a-3344d1c0fe48" />
|
||||
|
||||
# PreMiD
|
||||
# <img src="https://cdn.rcd.gg/PreMiD.png" height="40px" /> PreMiD
|
||||
|
||||
[](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/PreMiD/PreMiD)
|
||||
[](https://crowdin.com/project/premid)
|
||||
|
||||
This is the monorepo for PreMiD. PreMiD is a simple, configurable utility that allows you to show what you're watching/listening to on your Discord profile.
|
||||
PreMiD is a simple, configurable utility that lets you show what you're doing on the web in your Discord profile. Whether you're watching videos, listening to music, browsing your favorite sites, or playing browser games, PreMiD helps you share your online activities with your friends through Discord's Rich Presence feature.
|
||||
|
||||
## Getting Started
|
||||
|
||||
**If you are a user looking to install PreMiD, please visit the [official website](https://premid.app).**
|
||||
**Looking to use PreMiD?** Head over to our [official website](https://premid.app) to add the browser extension!
|
||||
|
||||
If you are a developer looking to contribute to PreMiD, read along.
|
||||
**Want to create your own Activity?** All of our community-created activities are open source and available at [github.com/PreMiD/Activities](https://github.com/PreMiD/Activities). We'd love to see what you create!
|
||||
|
||||
## Table of Contents
|
||||
## Features
|
||||
|
||||
- [Packages](#packages)
|
||||
- [License](#license)
|
||||
- 🎵 Show what you're listening to on YouTube and more (Spotify has native Discord support)
|
||||
- 📺 Display what you're watching on Netflix, Disney+, Twitch, and hundreds of other sites
|
||||
- 🎮 Share your browser game activity with friends
|
||||
- ✨ Fully customizable with thousands of user-created Activities
|
||||
- 🌍 Available in multiple languages thanks to our amazing community translators
|
||||
|
||||
## Packages
|
||||
## Community
|
||||
|
||||
This monorepo is split into multiple packages / projects. Here's a list of them:
|
||||
PreMiD is built by the community, for the community. Join us and help make PreMiD even better!
|
||||
|
||||
- [apps/api](apps/api) - The API for PreMiD.
|
||||
- [apps/website](apps/website) - The website for PreMiD.
|
||||
- [apps/docs](apps/docs) - The official documentation for PreMiD.
|
||||
- [apps/pd](apps/pd/README.md) - A simple url shortener service to shorten urls longer than 256 characters.
|
||||
- [apps/schema-server](apps/schema-server) - Simple Schema server for the Presence manifest.
|
||||
- [packages/db](packages/db) - Database schema for PreMiD.
|
||||
- **Activities Repository**: [github.com/PreMiD/Activities](https://github.com/PreMiD/Activities)
|
||||
- **Documentation**: [docs.premid.app](https://docs.premid.app)
|
||||
- **Discord Server**: [discord.premid.app](https://discord.premid.app)
|
||||
- **Feedback & Bug Reports**: [feedback.premid.app](https://feedback.premid.app)
|
||||
|
||||
## Development
|
||||
## Contributing
|
||||
|
||||
We love community contributions! While **PreMiD's Activities are fully open source** (the code that makes websites show up on your profile), the PreMiD extension is not currently open source. This decision allows our small team to move fast and iterate quickly to deliver the best experience possible.
|
||||
|
||||
You can contribute by:
|
||||
|
||||
- Creating new Activities at [github.com/PreMiD/Activities](https://github.com/PreMiD/Activities)
|
||||
- Helping translate PreMiD on [Crowdin](https://crowdin.com/project/premid)
|
||||
- Reporting bugs and suggesting features at [feedback.premid.app](https://feedback.premid.app)
|
||||
- Supporting the project and spreading the word!
|
||||
|
||||
### Release
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "@premid/api-master",
|
||||
"type": "module",
|
||||
"version": "0.0.18",
|
||||
"private": true,
|
||||
"description": "PreMiD's api master",
|
||||
"license": "MPL-2.0",
|
||||
"main": "dist/index.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "node --enable-source-maps .",
|
||||
"dev": "node --watch --env-file .env --enable-source-maps ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/rest": "^2.3.0",
|
||||
"@envelop/sentry": "^9.0.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.52.1",
|
||||
"@opentelemetry/node": "^0.24.0",
|
||||
"@sentry/node": "^8.17.0",
|
||||
"cron": "^3.1.7",
|
||||
"debug": "^4.3.6",
|
||||
"ioredis": "^5.3.2",
|
||||
"p-limit": "^6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.12"
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { REST } from "@discordjs/rest";
|
||||
import pLimit from "p-limit";
|
||||
import { mainLog, redis } from "../index.js";
|
||||
|
||||
let inProgress = false;
|
||||
export async function clearOldSessions() {
|
||||
if (inProgress) {
|
||||
mainLog("Session cleanup already in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
inProgress = true;
|
||||
const now = Date.now();
|
||||
let cursor = "0";
|
||||
let totalSessions = 0;
|
||||
let cleared = 0;
|
||||
const batchSize = 100;
|
||||
let keysToDelete: string[] = [];
|
||||
|
||||
mainLog("Starting session cleanup");
|
||||
|
||||
const limit = pLimit(100); // Create a limit of 100 concurrent operations
|
||||
|
||||
do {
|
||||
const [nextCursor, result] = await redis.hscan("pmd-api.sessions", cursor, "COUNT", batchSize);
|
||||
cursor = nextCursor;
|
||||
totalSessions += result.length / 2;
|
||||
|
||||
const deletePromises = [];
|
||||
|
||||
for (let i = 0; i < result.length; i += 2) {
|
||||
const key = result[i];
|
||||
const value = result[i + 1];
|
||||
|
||||
if (!key || !value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = JSON.parse(value) as {
|
||||
token: string;
|
||||
session: string;
|
||||
lastUpdated: number;
|
||||
};
|
||||
|
||||
if (now - session.lastUpdated < 30000)
|
||||
continue;
|
||||
|
||||
deletePromises.push(limit(() => deleteSession(session, key)));
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(deletePromises);
|
||||
results.forEach((result) => {
|
||||
if (result.status === "fulfilled" && result.value) {
|
||||
keysToDelete.push(result.value);
|
||||
cleared++;
|
||||
}
|
||||
});
|
||||
|
||||
if (keysToDelete.length >= batchSize) {
|
||||
await redis.hdel("pmd-api.sessions", ...keysToDelete);
|
||||
keysToDelete = [];
|
||||
}
|
||||
} while (cursor !== "0");
|
||||
|
||||
if (keysToDelete.length > 0) {
|
||||
await redis.hdel("pmd-api.sessions", ...keysToDelete);
|
||||
}
|
||||
|
||||
if (totalSessions === 0) {
|
||||
mainLog("No sessions to clear");
|
||||
}
|
||||
else {
|
||||
mainLog(`Checked ${totalSessions} sessions, cleared ${cleared}`);
|
||||
}
|
||||
|
||||
inProgress = false;
|
||||
}
|
||||
|
||||
async function deleteSession(session: { token: string; session: string }, key: string): Promise<string> {
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), 5000); //* 5 second timeout
|
||||
|
||||
try {
|
||||
const discord = new REST({ version: "10", authPrefix: "Bearer" });
|
||||
discord.setToken(session.token);
|
||||
|
||||
await discord.post("/users/@me/headless-sessions/delete", {
|
||||
signal: abortController.signal,
|
||||
body: {
|
||||
token: session.session,
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return key;
|
||||
}
|
||||
catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
//* Log detailed error information
|
||||
mainLog(`Delete session error for key ${key}:`, {
|
||||
errorName: error instanceof Error ? error.name : "Unknown",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorStack: error instanceof Error ? error.stack : "No stack trace",
|
||||
});
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
mainLog(`Session deletion aborted due to timeout for key ${key}`);
|
||||
}
|
||||
else if (error instanceof Error) {
|
||||
mainLog(`Failed to delete session for key ${key}: ${error.message}`);
|
||||
}
|
||||
else {
|
||||
mainLog(`Failed to delete session for key ${key}: Unknown error`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { hostname } from "node:os";
|
||||
import process from "node:process";
|
||||
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
/* c8 ignore start */
|
||||
export default function createRedis(): Redis {
|
||||
const redis = new Redis({
|
||||
connectionName: `api-master-${hostname()}-${process.pid.toString()}`,
|
||||
lazyConnect: true,
|
||||
name: "mymaster",
|
||||
sentinels: process.env.REDIS_SENTINELS?.split(",").map(s => ({
|
||||
host: s,
|
||||
port: 26_379,
|
||||
})),
|
||||
});
|
||||
|
||||
/* c8 ignore next 3 */
|
||||
redis.on("error", (error) => {
|
||||
console.error("Redis error", error);
|
||||
});
|
||||
|
||||
/* c8 ignore next 4 */
|
||||
redis.on("connect", () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Redis connected");
|
||||
});
|
||||
|
||||
return redis;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { redis } from "../index.js";
|
||||
import { counter } from "../tracing.js";
|
||||
|
||||
let activeActivities = 0;
|
||||
counter.add(0);
|
||||
export async function setCounter() {
|
||||
const length = await redis.hlen("pmd-api.sessions");
|
||||
if (length === activeActivities)
|
||||
return;
|
||||
const diff = length - activeActivities;
|
||||
activeActivities = length;
|
||||
counter.add(diff);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { CronJob } from "cron";
|
||||
|
||||
import debug from "debug";
|
||||
import { clearOldSessions } from "./functions/clearOldSessions.js";
|
||||
import createRedis from "./functions/createRedis.js";
|
||||
import { setCounter } from "./functions/setCounter.js";
|
||||
import "./tracing.js";
|
||||
|
||||
export const redis = createRedis();
|
||||
|
||||
export const mainLog = debug("api-master");
|
||||
|
||||
debug("Starting cron job to clear old sessions");
|
||||
|
||||
void new CronJob(
|
||||
// Every 5 seconds
|
||||
"*/5 * * * * *",
|
||||
() => {
|
||||
clearOldSessions();
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
void new CronJob(
|
||||
// Every second
|
||||
"* * * * * *",
|
||||
() => {
|
||||
setCounter();
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ValueType } from "@opentelemetry/api";
|
||||
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
const prometheusExporter = new PrometheusExporter();
|
||||
|
||||
const provider = new MeterProvider({
|
||||
readers: [prometheusExporter],
|
||||
});
|
||||
|
||||
const meter = provider.getMeter("nice");
|
||||
|
||||
export const counter = meter.createUpDownCounter("active_activites", {
|
||||
description: "Number of active activities",
|
||||
valueType: ValueType.INT,
|
||||
});
|
||||
|
||||
prometheusExporter.startServer();
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["environment.d.ts", "src", "codegen.ts"]
|
||||
}
|
||||
1
apps/api-worker/.gitignore
vendored
1
apps/api-worker/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
generated
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { CodegenConfig } from "@graphql-codegen/cli";
|
||||
|
||||
const config: CodegenConfig = {
|
||||
generates: {
|
||||
"dist/generated/schema-v5.graphql": {
|
||||
plugins: ["schema-ast"],
|
||||
schema: "src/graphql/schema/v5/**/*.gql",
|
||||
},
|
||||
"src/generated/graphql-v5.ts": {
|
||||
config: {
|
||||
scalars: {
|
||||
StringOrStringArray: "string | string[]",
|
||||
},
|
||||
},
|
||||
plugins: ["typescript", "typescript-resolvers"],
|
||||
schema: "src/graphql/schema/v5/**/*.gql",
|
||||
},
|
||||
"src/generated/schema-v5.graphql": {
|
||||
plugins: ["schema-ast"],
|
||||
schema: "src/graphql/schema/v5/**/*.gql",
|
||||
},
|
||||
},
|
||||
overwrite: true,
|
||||
};
|
||||
|
||||
export default config;
|
||||
7
apps/api-worker/environment.d.ts
vendored
7
apps/api-worker/environment.d.ts
vendored
@@ -1,7 +0,0 @@
|
||||
declare namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
NODE_ENV?: "development" | "production" | "test";
|
||||
DATABASE_URL?: string;
|
||||
SESSION_KEEP_ALIVE_INTERVAL?: string;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"name": "@premid/api-worker",
|
||||
"type": "module",
|
||||
"version": "0.0.8",
|
||||
"private": true,
|
||||
"description": "PreMiD's api",
|
||||
"license": "MPL-2.0",
|
||||
"main": "dist/index.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "node --enable-source-maps .",
|
||||
"dev": "node --watch --env-file .env --enable-source-maps .",
|
||||
"build": "pnpm codegen",
|
||||
"codegen": "graphql-codegen --config codegen.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/rest": "^2.3.0",
|
||||
"@envelop/sentry": "^9.0.0",
|
||||
"@escape.tech/graphql-armor-max-aliases": "^2.5.0",
|
||||
"@escape.tech/graphql-armor-max-depth": "^2.3.0",
|
||||
"@escape.tech/graphql-armor-max-directives": "^2.2.0",
|
||||
"@escape.tech/graphql-armor-max-tokens": "^2.4.0",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.52.1",
|
||||
"@opentelemetry/node": "^0.24.0",
|
||||
"@premid/db": "workspace:*",
|
||||
"@sentry/node": "^8.17.0",
|
||||
"arktype": "2.0.0-rc.6",
|
||||
"defu": "^6.1.4",
|
||||
"discord-api-types": "^0.37.92",
|
||||
"fastify": "^4.28.1",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-parse-resolve-info": "^4.13.0",
|
||||
"graphql-yoga": "^5.6.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"mongoose": "^8.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "5.0.2",
|
||||
"@graphql-codegen/schema-ast": "^4.1.0",
|
||||
"@graphql-codegen/typescript": "4.0.9",
|
||||
"@graphql-codegen/typescript-resolvers": "4.2.1",
|
||||
"@parcel/watcher": "^2.4.1",
|
||||
"@types/ws": "^8.5.12"
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { REST } from "@discordjs/rest";
|
||||
import { scope, type } from "arktype";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import WebSocket from "ws";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import type { RawData } from "ws";
|
||||
import { redis } from "../functions/createServer.js";
|
||||
import { counter } from "../tracing.js";
|
||||
|
||||
const schema = scope({
|
||||
token: {
|
||||
"+": "delete",
|
||||
"type": "'token'",
|
||||
"token": "string.trim",
|
||||
"expires": "number.epoch",
|
||||
},
|
||||
session: {
|
||||
"+": "delete",
|
||||
"type": "'session'",
|
||||
"token": "string.trim",
|
||||
},
|
||||
validMessages: "token | session",
|
||||
}).export();
|
||||
|
||||
export class Socket {
|
||||
currentToken: typeof schema.token.infer | undefined;
|
||||
currentSession: typeof schema.session.infer | undefined;
|
||||
discord = new REST({ version: "10", authPrefix: "Bearer" });
|
||||
|
||||
constructor(
|
||||
public readonly socket: WebSocket.WebSocket,
|
||||
public readonly request: FastifyRequest,
|
||||
) {
|
||||
counter.add(1);
|
||||
socket.on("message", this.onMessage.bind(this));
|
||||
socket.on("close", () => this.onClose());
|
||||
}
|
||||
|
||||
async onMessage(message: RawData) {
|
||||
try {
|
||||
const out = schema.validMessages(JSON.parse(message.toString()));
|
||||
|
||||
if (out instanceof type.errors) {
|
||||
return this.close(1003, out.summary);
|
||||
}
|
||||
|
||||
switch (out.type) {
|
||||
case "token": {
|
||||
this.discord.setToken(out.token);
|
||||
if (!await this.isTokenValid(out)) {
|
||||
return this.close(1003, "Invalid token");
|
||||
}
|
||||
this.currentToken = out;
|
||||
break;
|
||||
}
|
||||
case "session": {
|
||||
await redis.hdel("pmd-api.sessions", out.token);
|
||||
this.currentSession = out;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
this.close(1011, "Internal Error");
|
||||
}
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
counter.add(-1);
|
||||
|
||||
if (!this.currentToken || !this.currentSession)
|
||||
return;
|
||||
|
||||
await redis.hset(
|
||||
"pmd-api.sessions",
|
||||
this.currentSession.token,
|
||||
JSON.stringify({
|
||||
session: this.currentSession.token,
|
||||
token: this.currentToken.token,
|
||||
lastUpdated: Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async isTokenValid(token: typeof schema.token.infer) {
|
||||
// ? Check the expiration date of the token
|
||||
if (token.expires < Date.now())
|
||||
return false;
|
||||
|
||||
// ? See if we can get the user's information
|
||||
try {
|
||||
await this.discord.get(Routes.user());
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
send(data: any) {
|
||||
this.socket.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
close(code: number = 1000, message?: string) {
|
||||
if (this.socket.readyState === WebSocket.CLOSED)
|
||||
return;
|
||||
this.socket.close(code, message);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { hostname } from "node:os";
|
||||
import process from "node:process";
|
||||
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
/* c8 ignore start */
|
||||
export default function createRedis(): Redis {
|
||||
const redis = new Redis({
|
||||
connectionName: `api-${hostname()}-${process.pid.toString()}`,
|
||||
lazyConnect: true,
|
||||
name: "mymaster",
|
||||
sentinels: process.env.REDIS_SENTINELS?.split(",").map(s => ({
|
||||
host: s,
|
||||
port: 26_379,
|
||||
})),
|
||||
});
|
||||
|
||||
/* c8 ignore next 3 */
|
||||
redis.on("error", (error) => {
|
||||
console.error("Redis error", error);
|
||||
});
|
||||
|
||||
/* c8 ignore next 4 */
|
||||
redis.on("connect", () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Redis connected");
|
||||
});
|
||||
|
||||
return redis;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe.concurrent("createServer", () => {
|
||||
it("should create a server", async () => {
|
||||
const createServer = await import("./createServer.js");
|
||||
const server = await createServer.default();
|
||||
expect(server).toBeDefined();
|
||||
expect(server).toHaveProperty("listen");
|
||||
});
|
||||
|
||||
it("should handle graphql requests", async () => {
|
||||
const createServer = await import("./createServer.js");
|
||||
const server = await createServer.default();
|
||||
expect(server).toBeDefined();
|
||||
expect(server).toHaveProperty("listen");
|
||||
|
||||
const response = await server.inject({
|
||||
method: "GET",
|
||||
url: "/v5/graphql",
|
||||
});
|
||||
|
||||
expect(response).toBeDefined();
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
import { useSentry } from "@envelop/sentry";
|
||||
import { maxAliasesPlugin } from "@escape.tech/graphql-armor-max-aliases";
|
||||
import { maxDepthPlugin } from "@escape.tech/graphql-armor-max-depth";
|
||||
import { maxDirectivesPlugin } from "@escape.tech/graphql-armor-max-directives";
|
||||
import { maxTokensPlugin } from "@escape.tech/graphql-armor-max-tokens";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { defu } from "defu";
|
||||
import fastify from "fastify";
|
||||
|
||||
import { createSchema, createYoga } from "graphql-yoga";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { Socket } from "../classes/Socket.js";
|
||||
import { resolvers } from "../graphql/resolvers/v5/index.js";
|
||||
import { sessionKeepAlive } from "../routes/sessionKeepAlive.js";
|
||||
import createRedis from "./createRedis.js";
|
||||
|
||||
export interface FastifyContext {
|
||||
request: FastifyRequest;
|
||||
reply: FastifyReply;
|
||||
}
|
||||
|
||||
const __dirname = new URL(".", import.meta.url).pathname;
|
||||
|
||||
export default async function createServer() {
|
||||
const app = fastify({ logger: true });
|
||||
const yoga = createYoga<FastifyContext>({
|
||||
graphqlEndpoint: "/v5/graphql",
|
||||
logging: {
|
||||
/* c8 ignore next 12 */
|
||||
debug: (...arguments_) => {
|
||||
for (const argument of arguments_) app.log.debug(argument);
|
||||
},
|
||||
error: (...arguments_) => {
|
||||
for (const argument of arguments_) app.log.error(argument);
|
||||
},
|
||||
info: (...arguments_) => {
|
||||
for (const argument of arguments_) app.log.info(argument);
|
||||
},
|
||||
warn: (...arguments_) => {
|
||||
for (const argument of arguments_) app.log.warn(argument);
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
maxAliasesPlugin(),
|
||||
maxDepthPlugin(),
|
||||
maxDirectivesPlugin(),
|
||||
maxTokensPlugin(),
|
||||
useSentry(),
|
||||
],
|
||||
schema: createSchema<FastifyContext>({
|
||||
resolvers,
|
||||
typeDefs: await readFile(
|
||||
resolve(__dirname, "../generated/schema-v5.graphql"),
|
||||
"utf8",
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
app.route({
|
||||
handler: async (request, reply) => {
|
||||
const response = await yoga.handleNodeRequest(request, {
|
||||
reply,
|
||||
request,
|
||||
});
|
||||
for (const [key, value] of response.headers.entries())
|
||||
void reply.header(key, value);
|
||||
|
||||
void reply.status(response.status);
|
||||
|
||||
void reply.send(response.body);
|
||||
|
||||
return reply;
|
||||
},
|
||||
method: ["GET", "POST", "OPTIONS"],
|
||||
url: "/v5/graphql",
|
||||
});
|
||||
|
||||
app.register(fastifyWebsocket);
|
||||
|
||||
app.register(async (app) => {
|
||||
app.get("/v5/ws", { websocket: true }, (websocket, request) => {
|
||||
void new Socket(websocket, request);
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/v5/feature-flags", async (request, reply) => {
|
||||
const disabledFlags = process.env.DISABLED_FEATURE_FLAGS?.split(",") ?? [];
|
||||
const flags = Object.fromEntries(disabledFlags.map(flag => [flag, false]));
|
||||
|
||||
const test = defu(flags, {
|
||||
WebSocketManager: true,
|
||||
SessionKeepAlive: true,
|
||||
});
|
||||
|
||||
void reply.send(test);
|
||||
});
|
||||
|
||||
app.post("/v5/session-keep-alive", sessionKeepAlive);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export const redis = createRedis();
|
||||
@@ -1,32 +0,0 @@
|
||||
import { type } from "arktype";
|
||||
import { redis } from "../../../../functions/createServer.js";
|
||||
import type { MutationResolvers } from "../../../../generated/graphql-v5.js";
|
||||
|
||||
const addScienceSchema = type({
|
||||
identifier: "string.uuid & string.lower",
|
||||
presences: "string.trim[]",
|
||||
platform: {
|
||||
arch: "string.trim",
|
||||
os: "string.trim",
|
||||
},
|
||||
});
|
||||
|
||||
const mutation: MutationResolvers["addScience"] = async (_parent, input) => {
|
||||
const out = addScienceSchema(input);
|
||||
|
||||
if (out instanceof type.errors)
|
||||
throw new Error(out.summary);
|
||||
|
||||
await redis.hset(
|
||||
"pmd-api.scienceUpdates",
|
||||
out.identifier,
|
||||
JSON.stringify(out),
|
||||
);
|
||||
|
||||
return {
|
||||
__typename: "AddScienceResult",
|
||||
...out,
|
||||
};
|
||||
};
|
||||
|
||||
export default mutation;
|
||||
@@ -1,42 +0,0 @@
|
||||
import { type } from "arktype";
|
||||
import type { MutationResolvers } from "../../../../generated/graphql-v5.js";
|
||||
|
||||
const heartbeatSchema = type({
|
||||
identifier: "string.uuid & string.lower",
|
||||
presences: {
|
||||
service: "string.trim",
|
||||
version: "string.semver",
|
||||
language: "string.trim",
|
||||
since: "number.epoch",
|
||||
},
|
||||
extension: {
|
||||
"version": "string.semver",
|
||||
"language": "string.trim",
|
||||
"connected?": {
|
||||
app: "number.integer",
|
||||
discord: "boolean",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mutation: MutationResolvers["heartbeat"] = async (_parent, input) => {
|
||||
const out = heartbeatSchema(input);
|
||||
|
||||
if (out instanceof type.errors)
|
||||
throw new Error(out.summary);
|
||||
|
||||
// ! Disabled for now
|
||||
/* await redis.setex(
|
||||
`pmd-api.heartbeatUpdates.${data.identifier}`,
|
||||
// 5 minutes
|
||||
300,
|
||||
JSON.stringify(data)
|
||||
); */
|
||||
|
||||
return {
|
||||
__typename: "HeartbeatResult",
|
||||
...out,
|
||||
};
|
||||
};
|
||||
|
||||
export default mutation;
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { MutationResolvers } from "../../../../generated/graphql-v5.js";
|
||||
import addScience from "./addScience.js";
|
||||
import heartbeat from "./heartbeat.js";
|
||||
|
||||
export const Mutation: MutationResolvers = {
|
||||
addScience,
|
||||
heartbeat,
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { QueryResolvers } from "../../../../generated/graphql-v5.js";
|
||||
import presences from "./presences.js";
|
||||
|
||||
export const Query: QueryResolvers = {
|
||||
presences,
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Presence } from "@premid/db";
|
||||
import { parseResolveInfo } from "graphql-parse-resolve-info";
|
||||
import type { PresenceSchema } from "@premid/db/Presence.js";
|
||||
import type { FilterQuery } from "mongoose";
|
||||
|
||||
import type { QueryResolvers } from "../../../../generated/graphql-v5.js";
|
||||
|
||||
const resolver: QueryResolvers["presences"] = async (
|
||||
_parent,
|
||||
{ author, contributor, limit, query, service, start, tag },
|
||||
_context,
|
||||
info,
|
||||
) => {
|
||||
const authorFilter: FilterQuery<PresenceSchema> = author
|
||||
? { "metadata.author.name": author }
|
||||
: {};
|
||||
const contributorFilter: FilterQuery<PresenceSchema> = contributor
|
||||
? { "metadata.contributors.name": contributor }
|
||||
: {};
|
||||
const serviceFilter: FilterQuery<PresenceSchema> = service
|
||||
? Array.isArray(service)
|
||||
? { "metadata.service": { $in: service } }
|
||||
: { "metadata.service": service }
|
||||
: {};
|
||||
const queryFilter: FilterQuery<PresenceSchema> = query
|
||||
? { "metadata.service": { $options: "i", $regex: query } }
|
||||
: {};
|
||||
const tagFilter: FilterQuery<PresenceSchema> = tag
|
||||
? { "metadata.tags": tag }
|
||||
: {};
|
||||
|
||||
const presences = await Presence.find(
|
||||
{
|
||||
...authorFilter,
|
||||
...contributorFilter,
|
||||
...serviceFilter,
|
||||
...queryFilter,
|
||||
...tagFilter,
|
||||
},
|
||||
Object.assign(
|
||||
{},
|
||||
...Object.keys(parseResolveInfo(info)!.fieldsByTypeName.Presence!).map(
|
||||
fieldName => ({ [fieldName]: true }),
|
||||
),
|
||||
) as Record<string, boolean>,
|
||||
{ ...(limit ? { limit } : {}), ...(start ? { skip: start } : {}) },
|
||||
);
|
||||
|
||||
return presences.map(presence => ({
|
||||
iframeJs: presence.iframeJs,
|
||||
metadata: presence.metadata,
|
||||
presenceJs: presence.presenceJs,
|
||||
url: presence.url,
|
||||
users: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
export default resolver;
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { Resolvers } from "../../../generated/graphql-v5.js";
|
||||
import { Mutation } from "./Mutation/index.js";
|
||||
import { Query } from "./Query/index.js";
|
||||
|
||||
export const resolvers: Resolvers = {
|
||||
Query,
|
||||
Mutation,
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
type Mutation {
|
||||
addScience(identifier: String!, presences: [String!]!, platform: PlatformInput!): AddScienceResult
|
||||
}
|
||||
|
||||
input PlatformInput {
|
||||
arch: String!
|
||||
os: String!
|
||||
}
|
||||
|
||||
type AddScienceResult {
|
||||
identifier: String!
|
||||
presences: [String!]!
|
||||
platform: Platform!
|
||||
}
|
||||
|
||||
type Platform {
|
||||
arch: String!
|
||||
os: String!
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
type Query {
|
||||
"""
|
||||
Get the available languages
|
||||
"""
|
||||
availableLanguages: [Language!]!
|
||||
}
|
||||
|
||||
type Language {
|
||||
"""
|
||||
Language code
|
||||
"""
|
||||
lang: String!
|
||||
"""
|
||||
Native name of the language, eg. 'English', 'Deutsch', 'Español', etc.
|
||||
"""
|
||||
nativeName: String!
|
||||
"""
|
||||
'ltr' or 'rtl'
|
||||
"""
|
||||
direction: String!
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
type Query {
|
||||
"""
|
||||
Get the available presence languages for a specific presence
|
||||
"""
|
||||
availablePresenceLanguages(
|
||||
"""
|
||||
Presence, e.g. 'Netflix'
|
||||
"""
|
||||
presence: StringOrStringArray
|
||||
): [PresenceLanguage!]!
|
||||
}
|
||||
|
||||
type PresenceLanguage {
|
||||
"""
|
||||
Presence, e.g. 'Netflix'
|
||||
"""
|
||||
presence: String!
|
||||
"""
|
||||
The available languages for the presence
|
||||
"""
|
||||
languages: [Language!]!
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
type Mutation {
|
||||
heartbeat(
|
||||
identifier: String!
|
||||
presence: HeartbeatPresenceInput
|
||||
extension: HeartbeatExtensionInput!
|
||||
): HeartbeatResult!
|
||||
}
|
||||
|
||||
input HeartbeatPresenceInput {
|
||||
service: String!
|
||||
version: String!
|
||||
language: String!
|
||||
since: Float!
|
||||
}
|
||||
|
||||
input HeartbeatExtensionInput {
|
||||
version: String!
|
||||
language: String!
|
||||
connected: HeartbeatConnectedInput
|
||||
}
|
||||
|
||||
input HeartbeatConnectedInput {
|
||||
app: Int!
|
||||
discord: Boolean!
|
||||
}
|
||||
|
||||
type HeartbeatResult {
|
||||
identifier: String!
|
||||
presence: HeartbeatPresence
|
||||
extension: HeartbeatExtension!
|
||||
}
|
||||
|
||||
type HeartbeatPresence {
|
||||
service: String!
|
||||
version: String!
|
||||
language: String!
|
||||
since: Float!
|
||||
}
|
||||
|
||||
type HeartbeatExtension {
|
||||
version: String!
|
||||
language: String!
|
||||
connected: HeartbeatConnected
|
||||
}
|
||||
|
||||
type HeartbeatConnected {
|
||||
app: Int!
|
||||
discord: Boolean!
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
type Query {
|
||||
presences(
|
||||
service: StringOrStringArray
|
||||
author: String
|
||||
contributor: String
|
||||
start: Int
|
||||
limit: Int
|
||||
query: String
|
||||
tag: String
|
||||
): [Presence!]!
|
||||
}
|
||||
|
||||
type Presence {
|
||||
url: String!
|
||||
metadata: PresenceMetadata!
|
||||
presenceJs: String!
|
||||
iframeJs: String
|
||||
users: Int!
|
||||
}
|
||||
|
||||
type PresenceMetadata {
|
||||
author: PresenceMetadataUser!
|
||||
contributors: [PresenceMetadataUser!]
|
||||
altnames: [String!]
|
||||
service: String!
|
||||
description: Scalar! # serialize
|
||||
url: Scalar! # serialize
|
||||
version: String!
|
||||
logo: String!
|
||||
thumbnail: String!
|
||||
color: String!
|
||||
tags: [String!]!
|
||||
category: String!
|
||||
iframe: Boolean
|
||||
regExp: String
|
||||
iFrameRegExp: String
|
||||
readLogs: Boolean
|
||||
button: Boolean
|
||||
warning: Boolean
|
||||
settings: [PresenceMetadataSettings!]
|
||||
}
|
||||
|
||||
type PresenceMetadataUser {
|
||||
id: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
type PresenceMetadataSettings {
|
||||
id: String!
|
||||
title: String
|
||||
icon: String
|
||||
if: PresenceMetadataSettingsIf # serialize
|
||||
placeholder: String
|
||||
value: Scalar # serialize
|
||||
values: Scalar # serialize
|
||||
multiLanguage: Scalar # serialize
|
||||
}
|
||||
|
||||
type PresenceMetadataSettingsIf {
|
||||
propertyNames: String
|
||||
patternProperties: Scalar
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
scalar Scalar
|
||||
@@ -1 +0,0 @@
|
||||
scalar StringOrStringArray
|
||||
@@ -1,29 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
import process from "node:process";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { connect } from "mongoose";
|
||||
import "./tracing.js";
|
||||
|
||||
// eslint-disable-next-line perfectionist/sort-imports
|
||||
import createServer from "./functions/createServer.js";
|
||||
|
||||
// TODO SETUP SENTRY
|
||||
Sentry.init({
|
||||
integrations: [
|
||||
Sentry.graphqlIntegration(),
|
||||
Sentry.mongooseIntegration(),
|
||||
],
|
||||
});
|
||||
|
||||
if (!process.env.DATABASE_URL)
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
|
||||
await connect(process.env.DATABASE_URL, { appName: "PreMiD API" });
|
||||
|
||||
const server = await createServer();
|
||||
const url = await server.listen({
|
||||
port: Number.parseInt(process.env.PORT ?? "3001"),
|
||||
host: process.env.HOST ?? "0.0.0.0",
|
||||
});
|
||||
|
||||
console.log(`Server listening at ${url}`);
|
||||
@@ -1,56 +0,0 @@
|
||||
import process from "node:process";
|
||||
import { REST } from "@discordjs/rest";
|
||||
import { type } from "arktype";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { redis } from "../functions/createServer.js";
|
||||
|
||||
const schema = type({
|
||||
token: "string.trim",
|
||||
session: "string.trim",
|
||||
});
|
||||
|
||||
export async function sessionKeepAlive(request: FastifyRequest, reply: FastifyReply) {
|
||||
//* Get the 2 headers
|
||||
const out = schema({
|
||||
token: request.headers["x-token"],
|
||||
session: request.headers["x-session"],
|
||||
});
|
||||
|
||||
if (out instanceof type.errors)
|
||||
return reply.status(400).send({ code: "MISSING_HEADERS", message: out.message });
|
||||
|
||||
if (!await isTokenValid(out.token))
|
||||
return reply.status(400).send({ code: "INVALID_TOKEN", message: "The token is invalid" });
|
||||
|
||||
await redis.hset(
|
||||
"pmd-api.sessions",
|
||||
out.token,
|
||||
JSON.stringify({
|
||||
session: out.session,
|
||||
token: out.token,
|
||||
lastUpdated: Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
const interval = Number.parseInt(process.env.SESSION_KEEP_ALIVE_INTERVAL ?? "5000");
|
||||
|
||||
return reply.status(200).send({
|
||||
code: "OK",
|
||||
message: "Session updated",
|
||||
nextUpdate: interval,
|
||||
});
|
||||
}
|
||||
|
||||
async function isTokenValid(token: string) {
|
||||
const discord = new REST({ version: "10", authPrefix: "Bearer" });
|
||||
|
||||
discord.setToken(token);
|
||||
try {
|
||||
await discord.get(Routes.user());
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ValueType } from "@opentelemetry/api";
|
||||
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
const prometheusExporter = new PrometheusExporter();
|
||||
|
||||
const provider = new MeterProvider({
|
||||
readers: [prometheusExporter],
|
||||
});
|
||||
|
||||
const meter = provider.getMeter("nice");
|
||||
|
||||
export const counter = meter.createUpDownCounter("active_activites", {
|
||||
description: "Number of active activities",
|
||||
valueType: ValueType.INT,
|
||||
});
|
||||
|
||||
prometheusExporter.startServer();
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"types": ["@ark/schema"],
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["environment.d.ts", "src", "codegen.ts"]
|
||||
}
|
||||
1
apps/docs/.vitepress/.gitignore
vendored
1
apps/docs/.vitepress/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
cache
|
||||
@@ -1,129 +0,0 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
title: "Documentation",
|
||||
description: "Official Documentation",
|
||||
locales: {
|
||||
root: {
|
||||
label: "English",
|
||||
lang: "en-US",
|
||||
},
|
||||
de: {
|
||||
label: "Deutsch",
|
||||
lang: "de-DE",
|
||||
},
|
||||
},
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{
|
||||
text: "Presence Development",
|
||||
link: "/dev/getting-started",
|
||||
},
|
||||
{
|
||||
text: "Reference",
|
||||
link: "/reference/presence",
|
||||
},
|
||||
],
|
||||
sidebar: {
|
||||
"/default": {
|
||||
base: "/",
|
||||
items: [
|
||||
{
|
||||
text: "Getting Started",
|
||||
link: "/",
|
||||
items: [
|
||||
{
|
||||
text: "Introduction",
|
||||
link: "/introduction/",
|
||||
},
|
||||
{
|
||||
text: "Installation",
|
||||
link: "/installation/",
|
||||
},
|
||||
{
|
||||
text: "Setup",
|
||||
link: "/setup/",
|
||||
},
|
||||
{
|
||||
text: "FAQ",
|
||||
link: "/faq/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Development",
|
||||
link: "/development/",
|
||||
items: [
|
||||
{
|
||||
text: "Presence Development",
|
||||
link: "/presence-development/",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
text: "Getting Started",
|
||||
link: "/presence-development/getting-started/",
|
||||
},
|
||||
{
|
||||
text: "Creating a Presence",
|
||||
link: "/presence-development/creating-a-presence/",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Contribute",
|
||||
link: "/contribute/",
|
||||
items: [
|
||||
{
|
||||
text: "Report a Bug",
|
||||
link: "https://github.com/PreMiD",
|
||||
},
|
||||
{
|
||||
text: "Submit a Feature",
|
||||
link: "https://discord.premid.app",
|
||||
},
|
||||
{
|
||||
text: "Donate",
|
||||
link: "https://patreon.com/Timeraa",
|
||||
},
|
||||
{
|
||||
text: "Translate",
|
||||
link: "https://crowdin.com/project/premid",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"/reference/": {
|
||||
base: "/reference",
|
||||
items: [
|
||||
{
|
||||
text: "Reference",
|
||||
link: "/presence",
|
||||
items: [
|
||||
{
|
||||
text: "Presence",
|
||||
link: "/presence",
|
||||
},
|
||||
{
|
||||
text: "Iframe",
|
||||
link: "/iframe",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/PreMiD" },
|
||||
{ icon: "discord", link: "https://discord.premid.app" },
|
||||
{ icon: "x", link: "https://x.com/PreMiDapp" },
|
||||
],
|
||||
i18nRouting: true,
|
||||
logo: "/logo.svg",
|
||||
search: { provider: "local" },
|
||||
},
|
||||
lastUpdated: true,
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
# Creating a Presence
|
||||
|
||||
## Introduction
|
||||
|
||||
PreMiD Presences are the core of the PreMiD application. They allow you to add support for your favorite websites and services, or create your own custom Presences. This guide will walk you through the process of creating a Presence for PreMiD.
|
||||
|
||||
Please go through the [Getting Started](./getting-started) guide before proceeding with this guide. It will help you set up your development environment and install the necessary tools.
|
||||
|
||||
To make the process of creating a Presence easier, we have provided a command-line interface (CLI) tool. This tool will help you generate a new Presence project with all the necessary files and configurations, so you can start coding your Presence right away.
|
||||
|
||||
The CLI will also help you build and test your Presence before submitting it to the PreMiD Store. Testing is required to ensure your Presence works as expected and meets the quality standards. Proof that your Presence works is required for it to be approved. This is usually done by providing a video or a screenshot of your Presence in action.
|
||||
|
||||
## Creating a New Presence
|
||||
|
||||
To create a new Presence, we will run some scripts. This will generate a new Presence project with all the necessary files and configurations. To create a new Presence, follow these steps:
|
||||
|
||||
1. Open your terminal and run the following command:
|
||||
|
||||
```sh
|
||||
pnpm create
|
||||
```
|
||||
|
||||
2. Follow the on-screen instructions to create a new Presence project.
|
||||
|
||||
### Coding your Presence
|
||||
|
||||
Once you have created a new Presence project, you can start coding your Presence. First of all, you need to understand the structure of a Presence project.
|
||||
|
||||
#### Presence Structure
|
||||
|
||||
A Presence project consists of the following files and directories:
|
||||
|
||||
- `metadata.json`: This file contains the metadata for your Presence, such as the name, description, and version.
|
||||
- `presence.ts`: This file contains the code for your Presence. This is where you will write the logic to detect the presence of your website or service.
|
||||
- `iframe.ts` (optional): This file contains the code for the Presence's iframe. This is where we will be able to interact with embedded iframes on the website.
|
||||
|
||||
#### Development Server
|
||||
|
||||
Let's start a Development Server to build and test your Presence. Follow these steps:
|
||||
|
||||
1. Start a development server to be able to build and test your Presence:
|
||||
|
||||
```sh
|
||||
pnpm dev "Your Presence Name"
|
||||
```
|
||||
|
||||
2. Open your browser and go to the PreMiD Extension settings page, then enable Developer Mode.
|
||||
3. You should now see your new Presence in the list of Presences.
|
||||
|
||||
#### Editing the `presence.ts` File
|
||||
|
||||
In order to fetch the data from the website, you need to write the logic in the `presence.ts` file. We will use native JavaScript functions to fetch the data from the website. Let's see an example:
|
||||
|
||||
```ts
|
||||
const presence = new Presence({
|
||||
clientId: "Your Client ID",
|
||||
});
|
||||
|
||||
const enum Asset {
|
||||
Logo = "https://cdn.rcd.gg/PreMiD.png",
|
||||
}
|
||||
|
||||
presence.on("UpdateData", async () => {
|
||||
const title = document.querySelector("title");
|
||||
const description = document.querySelector("meta[name=\"description\"]");
|
||||
|
||||
const data: PresenceData = {
|
||||
details: title.textContent,
|
||||
state: description.getAttribute("content"),
|
||||
largeImageKey: Asset.Logo,
|
||||
};
|
||||
|
||||
presence.setActivity(data);
|
||||
});
|
||||
```
|
||||
|
||||
In this example, we are fetching the title and description of the website and setting them as the Presence details and state, respectively. We are also setting a custom logo as the large image key.
|
||||
@@ -1,50 +0,0 @@
|
||||
# Getting Started
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Git](https://git-scm.com/).
|
||||
- [Node.js](https://nodejs.org/) version 18 or higher, includes [Corepack](https://github.com/nodejs/corepack) by default.
|
||||
- Terminal for accessing PreMiD's Developer Tools via its command-line interface (CLI).
|
||||
- Text Editor with [TypeScript](https://www.typescriptlang.org/) syntax highlighting support.
|
||||
- [Visual Studio Code](https://code.visualstudio.com/) is recommended, as it includes TypeScript support out-of-the-box.
|
||||
|
||||
### Clone the Repository
|
||||
|
||||
- Open your terminal and run the following command:
|
||||
```sh
|
||||
git clone https://github.com/PreMiD/Presences.git
|
||||
```
|
||||
- Change your working directory to the repository:
|
||||
```sh
|
||||
cd Presences
|
||||
```
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
- Ensure you have Node.js installed by running:
|
||||
```sh
|
||||
node -v
|
||||
```
|
||||
If you see a version number, Node.js is installed. Make sure you have Node.js version 18 or higher.
|
||||
- Enable Corepack by running:
|
||||
```sh
|
||||
corepack enable
|
||||
```
|
||||
- Install the project dependencies:
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Coding your Presence
|
||||
|
||||
Follow the [Creating a Presence](./creating-a-presence) guide to get started with coding your own Presence.
|
||||
|
||||
## Submitting your Presence
|
||||
|
||||
Once you've finished coding your Presence, you can submit it to the PreMiD Store for others to use. Follow the [Submitting a Presence](./submitting-a-presence) guide to learn how to submit your Presence.
|
||||
|
||||
A member of the PreMiD Team will review your submission and if it meets the guidelines, it will be added to the PreMiD Store for everyone to use. If your submission is rejected, you will receive feedback on how to improve it.
|
||||
|
||||
Please note that all submissions are subject to review and approval by the PreMiD Team. We reserve the right to reject any submission that does not meet our guidelines or quality standards. Reviews may take up to 7 days to complete.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Getting Started
|
||||
|
||||
Welcome to the official documentation for PreMiD! This guide will help you get started with PreMiD.
|
||||
|
||||
## Installation
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
# https://vitepress.dev/reference/default-theme-home-page
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: "PreMiD"
|
||||
text: "Documentation"
|
||||
tagline: "The official documentation for PreMiD."
|
||||
image:
|
||||
src: /logo.svg
|
||||
alt: PreMiD Logo
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Get Started
|
||||
link: /getting-started
|
||||
- theme: alt
|
||||
text: Presence Development
|
||||
link: /dev/getting-started
|
||||
features:
|
||||
- icon: 🛠️
|
||||
title: Extensible
|
||||
details: Add Presences for your favorite websites and services. Or create your own!
|
||||
- icon: 🌐
|
||||
title: Cross-Platform
|
||||
details: PreMiD is available for all major browsers and platforms.
|
||||
- icon: 🚀
|
||||
title: Lightweight
|
||||
details: PreMiD is designed to be as lightweight as possible, so it won't slow down your system.
|
||||
---
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: -webkit-linear-gradient(120deg, rgb(209, 122, 254) 30%, rgb(89, 195, 246));
|
||||
|
||||
--vp-home-hero-image-background-image: linear-gradient(-45deg, rgb(209, 122, 254) 50%, rgb(89, 195, 246) 50%);
|
||||
--vp-home-hero-image-filter: blur(44px);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(56px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(68px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "@premid/docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Documentation for Premid",
|
||||
"scripts": {
|
||||
"dev": "vitepress dev",
|
||||
"build": "vitepress build",
|
||||
"preview": "vitepress preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitepress": "1.3.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 288 KiB |
@@ -1,68 +0,0 @@
|
||||
# Presence Class
|
||||
|
||||
The `Presence` class is the main class used to create a Presence.
|
||||
|
||||
## Overview
|
||||
|
||||
The `Presence` class is the main class used to create a Presence. It is used to interact with the PreMiD Extension.
|
||||
|
||||
### Example
|
||||
|
||||
```javascript
|
||||
const presence = new Presence({
|
||||
clientId: "<Your Client ID>",
|
||||
});
|
||||
|
||||
presence.on("UpdateData", () => {
|
||||
// Logic to update the presence data
|
||||
|
||||
presence.setActivity({
|
||||
details: "Example Presence",
|
||||
state: "Example State",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Constructor
|
||||
|
||||
### `new Presence(options: PresenceOptions)`
|
||||
|
||||
Creates a new `Presence` instance.
|
||||
|
||||
#### Parameters
|
||||
|
||||
- `options` (`PresenceOptions`): The options for the Presence.
|
||||
|
||||
#### Returns
|
||||
|
||||
- `Presence`: The new `Presence` instance.
|
||||
|
||||
## Properties
|
||||
|
||||
### `clientId: string`
|
||||
|
||||
The Client ID of the Presence.
|
||||
|
||||
## Methods
|
||||
|
||||
### `setActivity(activity: PresenceActivity)`
|
||||
|
||||
Sets the activity of the Presence.
|
||||
|
||||
#### Parameters
|
||||
|
||||
- `activity` (`PresenceActivity`): The activity to set.
|
||||
|
||||
### `clearActivity()`
|
||||
|
||||
Clears the activity of the Presence.
|
||||
|
||||
### `on(event: string, listener: Function)`
|
||||
|
||||
Adds a listener to an event.
|
||||
|
||||
#### Parameters
|
||||
|
||||
- `event` (`string`): The event to listen to.
|
||||
|
||||
- `listener` (`Function`): The listener to add.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@premid/pd",
|
||||
"type": "module",
|
||||
"version": "1.1.9",
|
||||
"version": "1.2.4",
|
||||
"private": true,
|
||||
"description": "A small service to shorten image urls to get around Discord's 256 character limit",
|
||||
"license": "MPL-2.0",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import createKeyv from "./functions/createKeyv.js";
|
||||
|
||||
export default createKeyv();
|
||||
export const keyv = createKeyv();
|
||||
|
||||
export const ttl = 30 * 60 * 1000;
|
||||
|
||||
@@ -5,7 +5,7 @@ import mime from "mime-types";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { RouteHandlerMethod } from "fastify";
|
||||
|
||||
import keyv from "../keyv.js";
|
||||
import { keyv, ttl } from "../keyv.js";
|
||||
|
||||
const handler: RouteHandlerMethod = async (request, reply) => {
|
||||
const { body } = request;
|
||||
@@ -33,16 +33,14 @@ const handler: RouteHandlerMethod = async (request, reply) => {
|
||||
const existingUrl = await keyv.get(hash);
|
||||
|
||||
if (existingUrl) {
|
||||
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
|
||||
return reply.send(process.env.BASE_URL! + existingUrl);
|
||||
}
|
||||
|
||||
const uniqueId = `${nanoid(10)}.${type}`;
|
||||
|
||||
await keyv.set(hash, uniqueId, 30 * 60 * 1000);
|
||||
await keyv.set(uniqueId, body, 30 * 60 * 1000);
|
||||
await keyv.set(hash, uniqueId, ttl);
|
||||
await keyv.set(uniqueId, body, ttl);
|
||||
|
||||
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
|
||||
return reply.send(process.env.BASE_URL! + uniqueId);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fileTypeFromBuffer } from "file-type";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { RouteHandlerMethod } from "fastify";
|
||||
|
||||
import keyv from "../keyv.js";
|
||||
import { keyv, ttl } from "../keyv.js";
|
||||
|
||||
const handler: RouteHandlerMethod = async (request, reply) => {
|
||||
if (!request.isMultipart())
|
||||
@@ -37,18 +37,16 @@ const handler: RouteHandlerMethod = async (request, reply) => {
|
||||
const existingUrl = await keyv.get(hash);
|
||||
|
||||
if (existingUrl) {
|
||||
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
|
||||
return reply.send(process.env.BASE_URL! + existingUrl);
|
||||
}
|
||||
|
||||
const uniqueId = `${nanoid(10)}.${type.ext}`;
|
||||
|
||||
await Promise.all([
|
||||
keyv.set(hash, uniqueId, 30 * 60 * 1000),
|
||||
keyv.set(uniqueId, body, 30 * 60 * 1000),
|
||||
keyv.set(hash, uniqueId, ttl),
|
||||
keyv.set(uniqueId, body, ttl),
|
||||
]);
|
||||
|
||||
void reply.header("Cache-control", `public, max-age=${(30 * 60).toString()}`);
|
||||
return reply.send(process.env.BASE_URL! + uniqueId);
|
||||
};
|
||||
|
||||
|
||||
@@ -61,4 +61,92 @@ describe.concurrent("/create", async () => {
|
||||
expect(result2.statusCode).toBe(200);
|
||||
expect(result2.body).toStrictEqual(body);
|
||||
});
|
||||
|
||||
it("should preserve file extension when URL has a valid image extension", async () => {
|
||||
const result = await server.inject({
|
||||
method: "GET",
|
||||
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.png`,
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.body).toStrictEqual(expect.any(String));
|
||||
expect(result.body).toMatch(/\.png$/);
|
||||
});
|
||||
|
||||
it("should preserve file extension when URL has .jpg extension", async () => {
|
||||
const result = await server.inject({
|
||||
method: "GET",
|
||||
url: `/create/https://www.exampl${"e".repeat(256)}.com/photo.jpg`,
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.body).toStrictEqual(expect.any(String));
|
||||
expect(result.body).toMatch(/\.jpg$/);
|
||||
});
|
||||
|
||||
it("should preserve file extension when URL has .webp extension", async () => {
|
||||
const result = await server.inject({
|
||||
method: "GET",
|
||||
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.webp`,
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.body).toStrictEqual(expect.any(String));
|
||||
expect(result.body).toMatch(/\.webp$/);
|
||||
});
|
||||
|
||||
it("should preserve file extension when URL has .png with query parameters", async () => {
|
||||
const result = await server.inject({
|
||||
method: "GET",
|
||||
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.png?ref=example`,
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.body).toStrictEqual(expect.any(String));
|
||||
expect(result.body).toMatch(/\.png$/);
|
||||
});
|
||||
|
||||
it("should preserve file extension when URL has .gif with complex query parameters", async () => {
|
||||
const result = await server.inject({
|
||||
method: "GET",
|
||||
url: `/create/https://www.exampl${"e".repeat(256)}.com/animated.gif?size=large&quality=high`,
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.body).toStrictEqual(expect.any(String));
|
||||
expect(result.body).toMatch(/\.gif$/);
|
||||
});
|
||||
|
||||
it("should not preserve file extension when URL has invalid extension", async () => {
|
||||
const result = await server.inject({
|
||||
method: "GET",
|
||||
url: `/create/https://www.exampl${"e".repeat(256)}.com/document.pdf`,
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.body).toStrictEqual(expect.any(String));
|
||||
expect(result.body).not.toMatch(/\.pdf$/);
|
||||
});
|
||||
|
||||
it("should work normally when URL has no file extension", async () => {
|
||||
const result = await server.inject({
|
||||
method: "GET",
|
||||
url: `/create/https://www.exampl${"e".repeat(256)}.com/page`,
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.body).toStrictEqual(expect.any(String));
|
||||
expect(result.body).not.toMatch(/\.\w+$/);
|
||||
});
|
||||
|
||||
it("should handle case-insensitive extensions", async () => {
|
||||
const result = await server.inject({
|
||||
method: "GET",
|
||||
url: `/create/https://www.exampl${"e".repeat(256)}.com/image.PNG`,
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.body).toStrictEqual(expect.any(String));
|
||||
expect(result.body).toMatch(/\.png$/); //* Should be lowercase in result
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import process from "node:process";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { RouteHandlerMethod } from "fastify";
|
||||
|
||||
import keyv from "../keyv.js";
|
||||
import { keyv, ttl } from "../keyv.js";
|
||||
|
||||
const handler: RouteHandlerMethod = async (request, reply) => {
|
||||
const url = request.url.replace("/create/", "").trim();
|
||||
@@ -19,19 +19,27 @@ const handler: RouteHandlerMethod = async (request, reply) => {
|
||||
if (!["http:", "https:"].includes(urlObject.protocol))
|
||||
return reply.status(400).send("Invalid URL");
|
||||
|
||||
//* Extract file extension from URL pathname
|
||||
const pathname = urlObject.pathname;
|
||||
const extensionMatch = pathname.match(/\.([^./]+)$/);
|
||||
const extension = extensionMatch?.[1]?.toLowerCase() ?? null;
|
||||
|
||||
//* Check if extension is in allowed list
|
||||
const allowedExtensions = ["png", "jpeg", "jpg", "gif", "webp"];
|
||||
const hasValidExtension = extension && allowedExtensions.includes(extension);
|
||||
|
||||
const hash = crypto.createHash("sha256").update(url).digest("hex");
|
||||
const existingShortenedUrl = await keyv.get(hash);
|
||||
|
||||
void reply.header("Cache-control", "public, max-age=1800");
|
||||
|
||||
if (existingShortenedUrl) {
|
||||
await Promise.all([keyv.set(hash, existingShortenedUrl, 1800), keyv.set(existingShortenedUrl, url, 1800)]);
|
||||
await Promise.all([keyv.set(hash, existingShortenedUrl, ttl), keyv.set(existingShortenedUrl, url, ttl)]);
|
||||
return reply.send(process.env.BASE_URL! + existingShortenedUrl);
|
||||
}
|
||||
|
||||
const uniqueId = nanoid(10);
|
||||
//* Create unique ID with extension if valid, otherwise without extension
|
||||
const uniqueId = hasValidExtension ? `${nanoid(10)}.${extension}` : nanoid(10);
|
||||
|
||||
await Promise.all([keyv.set(hash, uniqueId, 1800), keyv.set(uniqueId, url, 1800)]);
|
||||
await Promise.all([keyv.set(hash, uniqueId, ttl), keyv.set(uniqueId, url, ttl)]);
|
||||
|
||||
return reply.send(process.env.BASE_URL! + uniqueId);
|
||||
};
|
||||
|
||||
@@ -98,4 +98,31 @@ describe("getFullLink", async () => {
|
||||
expect(result.headers.get("content-type")).toBe("image/png");
|
||||
expect(Buffer.from(await result.arrayBuffer())).toStrictEqual(imageBuffer);
|
||||
});
|
||||
|
||||
it("should fetch and return PNG image instead of redirecting for URLs with .png extension", async () => {
|
||||
const testUrl = `https://cdn.rcd.gg/PreMiD/resources/reading.png?v=${"a".repeat(250)}`;
|
||||
|
||||
const { body } = await server.inject({
|
||||
url: `/create/${testUrl}`,
|
||||
});
|
||||
|
||||
expect(body).toStrictEqual(expect.any(String));
|
||||
|
||||
vi.spyOn(isInCIDRRange, "default").mockReturnValueOnce(true);
|
||||
|
||||
const result = await fetch(`${url}${body}`, {
|
||||
headers: {
|
||||
"cf-connecting-ip": "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.headers.get("content-type")).toMatch(/^image\//);
|
||||
//* Should return image data, not redirect
|
||||
expect(result.headers.get("location")).toBeNull();
|
||||
|
||||
//* Verify we got actual image data
|
||||
const imageBuffer = await result.arrayBuffer();
|
||||
expect(imageBuffer.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { RouteHandlerMethod } from "fastify";
|
||||
|
||||
import isInCIDRRange from "../functions/isInCidRange.js";
|
||||
import googleCIDRs from "../googleCIDRs.js";
|
||||
import keyv from "../keyv.js";
|
||||
import { keyv, ttl } from "../keyv.js";
|
||||
|
||||
const handler: RouteHandlerMethod = async (request, reply) => {
|
||||
/* c8 ignore next 2 */
|
||||
@@ -31,21 +31,63 @@ const handler: RouteHandlerMethod = async (request, reply) => {
|
||||
|
||||
const hash = crypto.createHash("sha256").update(url).digest("hex");
|
||||
|
||||
await Promise.all([keyv.set(hash, id, 30 * 60 * 1000), keyv.set(id, url, 30 * 60 * 1000)]);
|
||||
void reply.header("Cache-control", "public, max-age=1800");
|
||||
await Promise.all([keyv.set(hash, id, ttl), keyv.set(id, url, ttl)]);
|
||||
|
||||
//* If it is not a base64 string, redirect to it
|
||||
if (!url.startsWith("data:image"))
|
||||
return reply.redirect(url);
|
||||
//* If it is a base64 string, decode and return the image
|
||||
if (url.startsWith("data:image")) {
|
||||
const image = Buffer.from(
|
||||
url.replace(/^data:image\/\w+;base64,/, ""),
|
||||
"base64",
|
||||
);
|
||||
|
||||
const image = Buffer.from(
|
||||
url.replace(/^data:image\/\w+;base64,/, ""),
|
||||
"base64",
|
||||
);
|
||||
const mime = url.split(";")[0]!.split(":")[1]!;
|
||||
|
||||
const mime = url.split(";")[0]!.split(":")[1]!;
|
||||
return reply.type(mime).send(image);
|
||||
}
|
||||
|
||||
return reply.type(mime).send(image);
|
||||
//* Check if URL has a valid image extension
|
||||
const urlObject = new URL(url);
|
||||
const pathname = urlObject.pathname;
|
||||
const extensionMatch = pathname.match(/\.([^./]+)$/);
|
||||
const extension = extensionMatch?.[1]?.toLowerCase() ?? null;
|
||||
|
||||
const allowedExtensions = ["png", "jpeg", "jpg", "gif", "webp"];
|
||||
const hasValidImageExtension = extension && allowedExtensions.includes(extension);
|
||||
|
||||
//* If URL has valid image extension, fetch and return the image
|
||||
if (hasValidImageExtension) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Accept": "image/*",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
"Referer": urlObject.origin, //* Set referer to the origin domain to bypass hotlink protection
|
||||
"Sec-Fetch-Dest": "image",
|
||||
"Sec-Fetch-Mode": "no-cors",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return reply.code(404).send("Image not found");
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || `image/${extension}`;
|
||||
const imageBuffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
return reply.type(contentType).send(imageBuffer);
|
||||
}
|
||||
catch {
|
||||
//* If fetch fails, fall back to redirect
|
||||
return reply.redirect(url);
|
||||
}
|
||||
}
|
||||
|
||||
//* For all other URLs, redirect to them
|
||||
return reply.redirect(url);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@premid/schema-server",
|
||||
"type": "module",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.11",
|
||||
"private": true,
|
||||
"description": "A small service to serve the JSON schemas for PreMiD",
|
||||
"license": "MPL-2.0",
|
||||
|
||||
260
apps/schema-server/schemas/metadata/1.11.json
Normal file
260
apps/schema-server/schemas/metadata/1.11.json
Normal file
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://schemas.premid.app/metadata/1.11",
|
||||
"title": "Metadata",
|
||||
"type": "object",
|
||||
"description": "Metadata that describes a presence.",
|
||||
"definitions": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"description": "User information.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the user."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The Discord snowflake of the user.",
|
||||
"pattern": "^\\d+$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
|
||||
"type": "string",
|
||||
"description": "The metadata schema URL."
|
||||
},
|
||||
"author": {
|
||||
"$ref": "#/definitions/user",
|
||||
"description": "The author of this presence."
|
||||
},
|
||||
"contributors": {
|
||||
"type": "array",
|
||||
"description": "Any extra contributors to this presence.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/user"
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"description": "The service this presence is for."
|
||||
},
|
||||
"altnames": {
|
||||
"type": "array",
|
||||
"description": "Alternative names for the service.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "An alternative name."
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "object",
|
||||
"description": "A description of the presence in multiple languages.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The language key. The key must be languagecode(_REGIONCODE).",
|
||||
"pattern": "^[a-z]{2}(?:_(?:[A-Z]{2}|[0-9]{1,3}))?$"
|
||||
},
|
||||
"patternProperties": {
|
||||
"^[a-z]{2}(?:_(?:[A-Z]{2}|[0-9]{1,3}))?$": {
|
||||
"type": "string",
|
||||
"description": "The description of the presence in the key's language."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"en"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "One of the service's website URLs.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
|
||||
},
|
||||
"minItems": 2
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The SemVer version of the presence. Must just be major.minor.patch.",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "integer",
|
||||
"description": "The Presence System version this Presence supports.",
|
||||
"minimum": 1,
|
||||
"maximum": 2
|
||||
},
|
||||
"logo": {
|
||||
"type": "string",
|
||||
"description": "The logo of the service this presence is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string",
|
||||
"description": "A thumbnail of the service this presence is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "The theme color of the service this presence is for. Must be either a 6 digit or a 3 digit hex code.",
|
||||
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
|
||||
},
|
||||
"tags": {
|
||||
"type": [
|
||||
"array"
|
||||
],
|
||||
"description": "The tags for the presence.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "A tag.",
|
||||
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "The category the presence falls under.",
|
||||
"enum": [
|
||||
"anime",
|
||||
"games",
|
||||
"music",
|
||||
"socials",
|
||||
"videos",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"iframe": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the presence should run in IFrames."
|
||||
},
|
||||
"readLogs": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the extension should be reading logs."
|
||||
},
|
||||
"regExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match URLs for the presence to inject into."
|
||||
},
|
||||
"iFrameRegExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match IFrames for the presence to inject into."
|
||||
},
|
||||
"button": {
|
||||
"type": "boolean",
|
||||
"description": "Controls whether the presence is automatically added when the extension is installed. For partner presences only."
|
||||
},
|
||||
"warning": {
|
||||
"type": "boolean",
|
||||
"description": "Shows a warning saying that it requires additional steps for the presence to function correctly."
|
||||
},
|
||||
"settings": {
|
||||
"type": "array",
|
||||
"description": "An array of settings the user can change in the presence.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"description": "A setting.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
|
||||
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
|
||||
},
|
||||
"if": {
|
||||
"type": "object",
|
||||
"description": "Restrict showing this setting if another setting is the defined value.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"patternProperties": {
|
||||
"": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"placeholder": {
|
||||
"type": "string",
|
||||
"description": "The placeholder for settings that require input. Shown when the input is empty."
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The default value of the setting. Not compatible with `values`."
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"description": "The default values of the setting. Not compatible with `value`.",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"multiLanguage": {
|
||||
"type": [
|
||||
"string",
|
||||
"boolean",
|
||||
"array"
|
||||
],
|
||||
"description": "When false, multi-localization is disabled. When true, strings from the `general.json` file are available for use. When a string, it is the name of a file (excluding .json) of a used language from the localization GitHub repo. When an array of strings, it is all of the file names (excluding .json) of used languages from the localization GitHub repo.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "The name of a file from the localization GitHub repository."
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"author",
|
||||
"service",
|
||||
"description",
|
||||
"url",
|
||||
"version",
|
||||
"apiVersion",
|
||||
"logo",
|
||||
"thumbnail",
|
||||
"color",
|
||||
"tags",
|
||||
"category"
|
||||
]
|
||||
}
|
||||
252
apps/schema-server/schemas/metadata/1.12.json
Normal file
252
apps/schema-server/schemas/metadata/1.12.json
Normal file
@@ -0,0 +1,252 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://schemas.premid.app/metadata/1.12",
|
||||
"title": "Metadata",
|
||||
"type": "object",
|
||||
"description": "Metadata that describes a presence.",
|
||||
"definitions": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"description": "User information.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the user."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The Discord snowflake of the user.",
|
||||
"pattern": "^\\d+$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
|
||||
"type": "string",
|
||||
"description": "The metadata schema URL."
|
||||
},
|
||||
"author": {
|
||||
"$ref": "#/definitions/user",
|
||||
"description": "The author of this presence."
|
||||
},
|
||||
"contributors": {
|
||||
"type": "array",
|
||||
"description": "Any extra contributors to this presence.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/user"
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"description": "The service this presence is for."
|
||||
},
|
||||
"altnames": {
|
||||
"type": "array",
|
||||
"description": "Alternative names for the service.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "An alternative name."
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "object",
|
||||
"description": "A description of the presence in multiple languages.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The language key. The key must be languagecode(_REGIONCODE).",
|
||||
"pattern": "^[a-z]{2}(?:_(?:[A-Z]{2}|[0-9]{1,3}))?$"
|
||||
},
|
||||
"patternProperties": {
|
||||
"^[a-z]{2}(?:_(?:[A-Z]{2}|[0-9]{1,3}))?$": {
|
||||
"type": "string",
|
||||
"description": "The description of the presence in the key's language."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"en"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "One of the service's website URLs.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
|
||||
},
|
||||
"minItems": 2
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The SemVer version of the presence. Must just be major.minor.patch.",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "integer",
|
||||
"description": "The Presence System version this Presence supports.",
|
||||
"minimum": 1,
|
||||
"maximum": 2
|
||||
},
|
||||
"logo": {
|
||||
"type": "string",
|
||||
"description": "The logo of the service this presence is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string",
|
||||
"description": "A thumbnail of the service this presence is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "The theme color of the service this presence is for. Must be either a 6 digit or a 3 digit hex code.",
|
||||
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
|
||||
},
|
||||
"tags": {
|
||||
"type": [
|
||||
"array"
|
||||
],
|
||||
"description": "The tags for the presence.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "A tag.",
|
||||
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "The category the presence falls under.",
|
||||
"enum": [
|
||||
"anime",
|
||||
"games",
|
||||
"music",
|
||||
"socials",
|
||||
"videos",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"iframe": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the presence should run in IFrames."
|
||||
},
|
||||
"readLogs": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the extension should be reading logs."
|
||||
},
|
||||
"regExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match URLs for the presence to inject into."
|
||||
},
|
||||
"iFrameRegExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match IFrames for the presence to inject into."
|
||||
},
|
||||
"button": {
|
||||
"type": "boolean",
|
||||
"description": "Controls whether the presence is automatically added when the extension is installed. For partner presences only."
|
||||
},
|
||||
"warning": {
|
||||
"type": "boolean",
|
||||
"description": "Shows a warning saying that it requires additional steps for the presence to function correctly."
|
||||
},
|
||||
"settings": {
|
||||
"type": "array",
|
||||
"description": "An array of settings the user can change in the presence.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"description": "A setting.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
|
||||
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
|
||||
},
|
||||
"if": {
|
||||
"type": "object",
|
||||
"description": "Restrict showing this setting if another setting is the defined value.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"patternProperties": {
|
||||
"": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"placeholder": {
|
||||
"type": "string",
|
||||
"description": "The placeholder for settings that require input. Shown when the input is empty."
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The default value of the setting. Not compatible with `values`."
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"description": "The default values of the setting. Not compatible with `value`.",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"multiLanguage": {
|
||||
"type": "boolean",
|
||||
"description": "When true, strings from the `general.json` file are available for use, plus the <service>.json file. False is not allowed."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"author",
|
||||
"service",
|
||||
"description",
|
||||
"url",
|
||||
"version",
|
||||
"apiVersion",
|
||||
"logo",
|
||||
"thumbnail",
|
||||
"color",
|
||||
"tags",
|
||||
"category"
|
||||
]
|
||||
}
|
||||
248
apps/schema-server/schemas/metadata/1.13.json
Normal file
248
apps/schema-server/schemas/metadata/1.13.json
Normal file
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://schemas.premid.app/metadata/1.13",
|
||||
"title": "Metadata",
|
||||
"type": "object",
|
||||
"description": "Metadata that describes a activity.",
|
||||
"definitions": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"description": "User information.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the user."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The Discord snowflake of the user.",
|
||||
"pattern": "^\\d+$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
|
||||
"type": "string",
|
||||
"description": "The metadata schema URL."
|
||||
},
|
||||
"author": {
|
||||
"$ref": "#/definitions/user",
|
||||
"description": "The author of this activity."
|
||||
},
|
||||
"contributors": {
|
||||
"type": "array",
|
||||
"description": "Any extra contributors to this activity.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/user"
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"description": "The service this activity is for."
|
||||
},
|
||||
"altnames": {
|
||||
"type": "array",
|
||||
"description": "Alternative names for the service.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "An alternative name."
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "object",
|
||||
"description": "A description of the activity in multiple languages.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The language key. The key must be languagecode(-regioncode).",
|
||||
"pattern": "^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$"
|
||||
},
|
||||
"patternProperties": {
|
||||
"^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$": {
|
||||
"type": "string",
|
||||
"description": "The description of the activity in the key's language."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"en"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "One of the service's website URLs.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
|
||||
},
|
||||
"minItems": 2
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The SemVer version of the activity. Must just be major.minor.patch.",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "integer",
|
||||
"description": "The Activity System version this activity supports.",
|
||||
"minimum": 1,
|
||||
"maximum": 2
|
||||
},
|
||||
"logo": {
|
||||
"type": "string",
|
||||
"description": "The logo of the service this activity is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string",
|
||||
"description": "A thumbnail of the service this activity is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "The theme color of the service this activity is for. Must be either a 6 digit or a 3 digit hex code.",
|
||||
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
|
||||
},
|
||||
"tags": {
|
||||
"type": [
|
||||
"array"
|
||||
],
|
||||
"description": "The tags for the activity.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "A tag.",
|
||||
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "The category the activity falls under.",
|
||||
"enum": [
|
||||
"anime",
|
||||
"games",
|
||||
"music",
|
||||
"socials",
|
||||
"videos",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"iframe": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the activity should run in IFrames."
|
||||
},
|
||||
"readLogs": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the extension should be reading logs."
|
||||
},
|
||||
"regExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match URLs for the activity to inject into."
|
||||
},
|
||||
"iFrameRegExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match IFrames for the activity to inject into."
|
||||
},
|
||||
"settings": {
|
||||
"type": "array",
|
||||
"description": "An array of settings the user can change in the activity.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"description": "A setting.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
|
||||
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
|
||||
},
|
||||
"if": {
|
||||
"type": "object",
|
||||
"description": "Restrict showing this setting if another setting is the defined value.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"patternProperties": {
|
||||
"": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"placeholder": {
|
||||
"type": "string",
|
||||
"description": "The placeholder for settings that require input. Shown when the input is empty."
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The default value of the setting. Not compatible with `values`."
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"description": "The default values of the setting. Not compatible with `value`.",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"multiLanguage": {
|
||||
"type": "boolean",
|
||||
"description": "When true, strings from the `general.json` file are available for use, plus the <service>.json file. False is not allowed."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the activity has support for mobile devices."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"author",
|
||||
"service",
|
||||
"description",
|
||||
"url",
|
||||
"version",
|
||||
"apiVersion",
|
||||
"logo",
|
||||
"thumbnail",
|
||||
"color",
|
||||
"tags",
|
||||
"category"
|
||||
]
|
||||
}
|
||||
252
apps/schema-server/schemas/metadata/1.14.json
Normal file
252
apps/schema-server/schemas/metadata/1.14.json
Normal file
@@ -0,0 +1,252 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://schemas.premid.app/metadata/1.14",
|
||||
"title": "Metadata",
|
||||
"type": "object",
|
||||
"description": "Metadata that describes a activity.",
|
||||
"definitions": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"description": "User information.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the user."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The Discord snowflake of the user.",
|
||||
"pattern": "^\\d+$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
|
||||
"type": "string",
|
||||
"description": "The metadata schema URL."
|
||||
},
|
||||
"author": {
|
||||
"$ref": "#/definitions/user",
|
||||
"description": "The author of this activity."
|
||||
},
|
||||
"contributors": {
|
||||
"type": "array",
|
||||
"description": "Any extra contributors to this activity.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/user"
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"description": "The service this activity is for."
|
||||
},
|
||||
"altnames": {
|
||||
"type": "array",
|
||||
"description": "Alternative names for the service.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "An alternative name."
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "object",
|
||||
"description": "A description of the activity in multiple languages.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The language key. The key must be languagecode(-regioncode).",
|
||||
"pattern": "^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$"
|
||||
},
|
||||
"patternProperties": {
|
||||
"^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$": {
|
||||
"type": "string",
|
||||
"description": "The description of the activity in the key's language."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"en"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "One of the service's website URLs.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
|
||||
},
|
||||
"minItems": 2
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The SemVer version of the activity. Must just be major.minor.patch.",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "integer",
|
||||
"description": "The Activity System version this activity supports.",
|
||||
"minimum": 1,
|
||||
"maximum": 2
|
||||
},
|
||||
"logo": {
|
||||
"type": "string",
|
||||
"description": "The logo of the service this activity is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string",
|
||||
"description": "A thumbnail of the service this activity is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "The theme color of the service this activity is for. Must be either a 6 digit or a 3 digit hex code.",
|
||||
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
|
||||
},
|
||||
"tags": {
|
||||
"type": [
|
||||
"array"
|
||||
],
|
||||
"description": "The tags for the activity.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "A tag.",
|
||||
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "The category the activity falls under.",
|
||||
"enum": [
|
||||
"anime",
|
||||
"games",
|
||||
"music",
|
||||
"socials",
|
||||
"videos",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"iframe": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the activity should run in IFrames."
|
||||
},
|
||||
"readLogs": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the extension should be reading logs."
|
||||
},
|
||||
"regExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match URLs for the activity to inject into."
|
||||
},
|
||||
"iFrameRegExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match IFrames for the activity to inject into."
|
||||
},
|
||||
"settings": {
|
||||
"type": "array",
|
||||
"description": "An array of settings the user can change in the activity.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"description": "A setting.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "The description of the setting. Only applicable if `multiLanguage` is disabled."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
|
||||
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
|
||||
},
|
||||
"if": {
|
||||
"type": "object",
|
||||
"description": "Restrict showing this setting if another setting is the defined value.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"patternProperties": {
|
||||
"": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"placeholder": {
|
||||
"type": "string",
|
||||
"description": "The placeholder for settings that require input. Shown when the input is empty."
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The default value of the setting. Not compatible with `values`."
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"description": "The default values of the setting. Not compatible with `value`.",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"multiLanguage": {
|
||||
"type": "boolean",
|
||||
"description": "When true, strings from the `general.json` file are available for use, plus the <service>.json file. False is not allowed."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the activity has support for mobile devices."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"author",
|
||||
"service",
|
||||
"description",
|
||||
"url",
|
||||
"version",
|
||||
"apiVersion",
|
||||
"logo",
|
||||
"thumbnail",
|
||||
"color",
|
||||
"tags",
|
||||
"category"
|
||||
]
|
||||
}
|
||||
256
apps/schema-server/schemas/metadata/1.15.json
Normal file
256
apps/schema-server/schemas/metadata/1.15.json
Normal file
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://schemas.premid.app/metadata/1.15",
|
||||
"title": "Metadata",
|
||||
"type": "object",
|
||||
"description": "Metadata that describes a activity.",
|
||||
"definitions": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"description": "User information.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the user."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The Discord snowflake of the user.",
|
||||
"pattern": "^\\d+$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
|
||||
"type": "string",
|
||||
"description": "The metadata schema URL."
|
||||
},
|
||||
"author": {
|
||||
"$ref": "#/definitions/user",
|
||||
"description": "The author of this activity."
|
||||
},
|
||||
"contributors": {
|
||||
"type": "array",
|
||||
"description": "Any extra contributors to this activity.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/user"
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"description": "The service this activity is for."
|
||||
},
|
||||
"altnames": {
|
||||
"type": "array",
|
||||
"description": "Alternative names for the service.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "An alternative name."
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "object",
|
||||
"description": "A description of the activity in multiple languages.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The language key. The key must be languagecode(-regioncode).",
|
||||
"pattern": "^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$"
|
||||
},
|
||||
"patternProperties": {
|
||||
"^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$": {
|
||||
"type": "string",
|
||||
"description": "The description of the activity in the key's language."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"en"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"description": "The service's website URL, or an array of URLs. Protocols should not be added.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "One of the service's website URLs.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
|
||||
},
|
||||
"minItems": 2
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The SemVer version of the activity. Must just be major.minor.patch.",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "integer",
|
||||
"description": "The Activity System version this activity supports.",
|
||||
"minimum": 1,
|
||||
"maximum": 2
|
||||
},
|
||||
"logo": {
|
||||
"type": "string",
|
||||
"description": "The logo of the service this activity is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string",
|
||||
"description": "A thumbnail of the service this activity is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "The theme color of the service this activity is for. Must be either a 6 digit or a 3 digit hex code.",
|
||||
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
|
||||
},
|
||||
"tags": {
|
||||
"type": [
|
||||
"array"
|
||||
],
|
||||
"description": "The tags for the activity.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "A tag.",
|
||||
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "The category the activity falls under.",
|
||||
"enum": [
|
||||
"anime",
|
||||
"games",
|
||||
"music",
|
||||
"socials",
|
||||
"videos",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"iframe": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the activity should run in IFrames."
|
||||
},
|
||||
"readLogs": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the extension should be reading logs."
|
||||
},
|
||||
"regExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match URLs for the activity to inject into."
|
||||
},
|
||||
"iFrameRegExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match IFrames for the activity to inject into."
|
||||
},
|
||||
"allowURLOverrides": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the activity should allow URL overrides."
|
||||
},
|
||||
"settings": {
|
||||
"type": "array",
|
||||
"description": "An array of settings the user can change in the activity.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"description": "A setting.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "The description of the setting. Only applicable if `multiLanguage` is disabled."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
|
||||
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
|
||||
},
|
||||
"if": {
|
||||
"type": "object",
|
||||
"description": "Restrict showing this setting if another setting is the defined value.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"patternProperties": {
|
||||
"": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"placeholder": {
|
||||
"type": "string",
|
||||
"description": "The placeholder for settings that require input. Shown when the input is empty."
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The default value of the setting. Not compatible with `values`."
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"description": "The default values of the setting. Not compatible with `value`.",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"multiLanguage": {
|
||||
"type": "boolean",
|
||||
"description": "When true, strings from the `general.json` file are available for use, plus the <service>.json file. False is not allowed."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the activity has support for mobile devices."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"author",
|
||||
"service",
|
||||
"description",
|
||||
"url",
|
||||
"version",
|
||||
"apiVersion",
|
||||
"logo",
|
||||
"thumbnail",
|
||||
"color",
|
||||
"tags",
|
||||
"category"
|
||||
]
|
||||
}
|
||||
255
apps/schema-server/schemas/metadata/1.16.json
Normal file
255
apps/schema-server/schemas/metadata/1.16.json
Normal file
@@ -0,0 +1,255 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://schemas.premid.app/metadata/1.16",
|
||||
"title": "Metadata",
|
||||
"type": "object",
|
||||
"description": "Metadata that describes an activity.",
|
||||
"definitions": {
|
||||
"user": {
|
||||
"type": "object",
|
||||
"description": "User information.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the user."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The Discord snowflake of the user.",
|
||||
"pattern": "^\\d+$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"$comment": "This is required otherwise the schema will fail itself when it is applied to a document via $schema. This is optional so that validators that use this schema don't fail if the metadata doesn't have the $schema property.",
|
||||
"type": "string",
|
||||
"description": "The metadata schema URL."
|
||||
},
|
||||
"author": {
|
||||
"$ref": "#/definitions/user",
|
||||
"description": "The author of this activity."
|
||||
},
|
||||
"contributors": {
|
||||
"type": "array",
|
||||
"description": "Any extra contributors to this activity.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/user"
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"description": "The service this activity is for."
|
||||
},
|
||||
"altnames": {
|
||||
"type": "array",
|
||||
"description": "Alternative names for the service.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "An alternative name."
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "object",
|
||||
"description": "A description of the activity in multiple languages.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The language key in the format: languagecode or languagecode-regioncode.",
|
||||
"pattern": "^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$"
|
||||
},
|
||||
"patternProperties": {
|
||||
"^[a-z]{2,3}(?:-(?:[a-z]{2}|[0-9]{1,3}))?$": {
|
||||
"type": "string",
|
||||
"description": "The description of the activity in the key's language."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"en"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"description": "The service's website URL, or an array of URLs. Protocols should not be added. Not used for matching.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "One of the service's website URLs.",
|
||||
"pattern": "^(([a-z0-9-]+\\.)*[0-9a-z_-]+(\\.[a-z]+)+|(\\d{1,3}\\.){3}\\d{1,3}|localhost)$"
|
||||
},
|
||||
"minItems": 2
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "The SemVer version of the activity in the format: major.minor.patch.",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "integer",
|
||||
"description": "The Activity System version this activity supports.",
|
||||
"minimum": 1,
|
||||
"maximum": 2
|
||||
},
|
||||
"logo": {
|
||||
"type": "string",
|
||||
"description": "The logo of the service this activity is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string",
|
||||
"description": "A thumbnail of the service this activity is for.",
|
||||
"pattern": "^https?://.+\\.(png|jpe?g|gif|webp)$"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "The theme color of the service this activity is for. Must be either a 6 digit or a 3 digit hex code.",
|
||||
"pattern": "^#([A-Fa-f0-9]{3}){1,2}$"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"description": "The tags for the activity.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "A tag.",
|
||||
"pattern": "^[^A-Z\\s!\"#$%&'()*+,./:;<=>?@\\[\\\\\\]^_`{|}~]+$"
|
||||
},
|
||||
"minItems": 1
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "The category the activity falls under.",
|
||||
"enum": [
|
||||
"anime",
|
||||
"games",
|
||||
"music",
|
||||
"socials",
|
||||
"videos",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"iframe": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the activity should run in iframes."
|
||||
},
|
||||
"readLogs": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the extension should be reading logs."
|
||||
},
|
||||
"regExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match URLs for the activity to inject into."
|
||||
},
|
||||
"iFrameRegExp": {
|
||||
"type": "string",
|
||||
"description": "A regular expression used to match iframes for the activity to inject into."
|
||||
},
|
||||
"allowURLOverrides": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the activity should allow the user to override the activity's matching regExps."
|
||||
},
|
||||
"settings": {
|
||||
"type": "array",
|
||||
"description": "An array of settings the user can change in the activity.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"description": "A setting.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The title of the setting. Required only if `multiLanguage` is disabled."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "The description of the setting. Only applicable if `multiLanguage` is disabled."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "The icon of the setting. Required only if `multiLanguage` is disabled.",
|
||||
"pattern": "^fa([bsdrlt]|([-](brands|solid|duotone|regular|light|thin))) fa-[0-9a-z-]+$"
|
||||
},
|
||||
"if": {
|
||||
"type": "object",
|
||||
"description": "Restrict showing this setting if another setting is the defined value.",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"description": "The ID of the setting."
|
||||
},
|
||||
"patternProperties": {
|
||||
"": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"placeholder": {
|
||||
"type": "string",
|
||||
"description": "The placeholder for settings that require input. Shown when the input is empty."
|
||||
},
|
||||
"value": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The default value of the setting. Not compatible with `values`."
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"description": "The default values of the setting. Not compatible with `value`.",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean"
|
||||
],
|
||||
"description": "The value of the setting."
|
||||
}
|
||||
},
|
||||
"multiLanguage": {
|
||||
"type": "boolean",
|
||||
"description": "When true, strings from the `general.json` file are available for use, plus the <service>.json file. False is not allowed."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"mobile": {
|
||||
"type": "boolean",
|
||||
"description": "Whether or not the activity has support for mobile devices."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"author",
|
||||
"service",
|
||||
"description",
|
||||
"url",
|
||||
"version",
|
||||
"apiVersion",
|
||||
"logo",
|
||||
"thumbnail",
|
||||
"color",
|
||||
"tags",
|
||||
"category",
|
||||
"regExp"
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
NUXT_DISCORD_BOT_TOKEN=""
|
||||
24
apps/website/.gitignore
vendored
24
apps/website/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -1,373 +0,0 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -1,77 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useExtensionStore } from "./stores/useExtension";
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: "en",
|
||||
},
|
||||
bodyAttrs: {
|
||||
id: "app",
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
href: "/assets/images/icon.png",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
//* Cloudflare specific
|
||||
if (import.meta.env.PROD) {
|
||||
useHead({
|
||||
script: [
|
||||
{ src: "/cdn-cgi/challenge-platform/scripts/jsd/main.js", crossorigin: "anonymous", referrerpolicy: "origin" },
|
||||
],
|
||||
}, { mode: "client" });
|
||||
}
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
useSeoMeta({
|
||||
title: "PreMiD",
|
||||
ogTitle: "PreMiD",
|
||||
description: t("page.home.description"),
|
||||
ogDescription: t("page.home.description"),
|
||||
ogImage: "https://cdn.rcd.gg/PreMiD.png",
|
||||
twitterCard: "summary_large_image",
|
||||
ogUrl: "https://premid.app",
|
||||
twitterTitle: "PreMiD",
|
||||
twitterDescription: t("page.home.description"),
|
||||
applicationName: "PreMiD",
|
||||
twitterImage: "https://cdn.rcd.gg/PreMiD.png",
|
||||
});
|
||||
|
||||
/* useScriptGoogleAdsense({
|
||||
client: "ca-pub-1575460061917202",
|
||||
}); */
|
||||
|
||||
const extension = useExtensionStore();
|
||||
extension.setupGlobalEvents();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtLoadingIndicator
|
||||
color="#7289da"
|
||||
:height="4"
|
||||
/>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition:
|
||||
opacity 0.3s ease-in-out,
|
||||
transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +0,0 @@
|
||||
const breakpoints = {
|
||||
"2xl": "1536px",
|
||||
"lg": "1200px",
|
||||
"md": "768px",
|
||||
"sm": "640px",
|
||||
"xl": "1280px",
|
||||
"xs": "480px",
|
||||
};
|
||||
|
||||
export default breakpoints;
|
||||
@@ -1,79 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
const { browser } = defineProps<{
|
||||
browser: string;
|
||||
highlight?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["click"]);
|
||||
|
||||
const isWIP = ref(false);
|
||||
|
||||
function getIcon(browser: string) {
|
||||
switch (browser) {
|
||||
case "Safari":
|
||||
isWIP.value = true;
|
||||
return "fa-brands fa-safari";
|
||||
case "Edge":
|
||||
return "fa-brands fa-edge";
|
||||
case "Firefox":
|
||||
return "fa-brands fa-firefox";
|
||||
case "Opera":
|
||||
return "fa-brands fa-opera";
|
||||
case "Brave":
|
||||
return "fa-brands fa-brave";
|
||||
case "Chrome":
|
||||
return "fa-brands fa-chrome";
|
||||
default:
|
||||
return ["fa-brands fa-chrome", "fa-brands fa-brave", "fa-brands fa-opera", "fa-brands fa-edge"];
|
||||
}
|
||||
}
|
||||
|
||||
let iconUpdateInterval: number | undefined;
|
||||
const iconIndex = ref<number>(0);
|
||||
|
||||
const currentIcon = computed(() => {
|
||||
if (!Array.isArray(getIcon(browser)))
|
||||
return getIcon(browser);
|
||||
|
||||
return getIcon(browser)[iconIndex.value];
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof getIcon(browser) === "string")
|
||||
return;
|
||||
|
||||
iconIndex.value = 0;
|
||||
iconUpdateInterval = window.setInterval(() => {
|
||||
iconIndex.value = (iconIndex.value + 1) % getIcon(browser).length;
|
||||
if (iconIndex.value >= getIcon(browser).length) {
|
||||
iconIndex.value = 0;
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(iconUpdateInterval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<VTooltip :disabled="!isWIP">
|
||||
<div class="flex items-center font-bold cursor-pointer transition-colors relative select-none bg-gray gap-2 px5 border-rounded w-50 h-20" :class="[highlight && !isWIP ? 'bg-primary hover:bg-primary-highlight c-black' : '', isWIP ? 'bg-op-60 cursor-not-allowed' : 'hover:bg-primary']" @click="!isWIP && emit('click')">
|
||||
<FAIcon
|
||||
class="h-auto mr-2 w-7"
|
||||
:icon="currentIcon"
|
||||
/>
|
||||
<span>
|
||||
{{ browser }}
|
||||
</span>
|
||||
<span v-if="isWIP" class="rounded-full absolute text-ellipsis overflow-hidden py-1 whitespace-nowrap bg-red-500 top--2 right--2 px-2 max-w-25 max-h-7">
|
||||
{{ $t("component.browserCard.wip") }}
|
||||
</span>
|
||||
</div>
|
||||
<template #popper>
|
||||
{{ $t("component.browserCard.support.safari") }}
|
||||
</template>
|
||||
</VTooltip>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import tinycolor from "tinycolor2";
|
||||
import type { ContributorsQuery } from "#gql";
|
||||
|
||||
type ContributorType = NonNullable<ContributorsQuery["credits"]>[number];
|
||||
|
||||
const { user } = defineProps<{ user: ContributorType }>();
|
||||
|
||||
const hovered = ref(false);
|
||||
|
||||
// eslint-disable-next-line one-var
|
||||
const cardGradientColor = computed(() => {
|
||||
return {
|
||||
primary: tinycolor(user?.user?.roleColor ?? "")
|
||||
.setAlpha(1)
|
||||
.darken(5)
|
||||
.toRgbString(),
|
||||
secondary: tinycolor(user?.user?.roleColor ?? "")
|
||||
.analogous()[2]
|
||||
.setAlpha(0.5)
|
||||
.saturate(20)
|
||||
.toRgbString(),
|
||||
};
|
||||
}),
|
||||
cardShadowColor = computed(() =>
|
||||
hovered.value
|
||||
? tinycolor(cardGradientColor.value.primary)
|
||||
.setAlpha(0.3)
|
||||
.saturate(20)
|
||||
.toRgbString()
|
||||
: "transparent",
|
||||
),
|
||||
computedBackground = computed(() => {
|
||||
return `background: linear-gradient(-35deg, ${cardGradientColor.value.secondary} 20%, ${cardGradientColor.value.primary} 130%); box-shadow: 0 2px 52px 0 ${cardShadowColor.value}`;
|
||||
});
|
||||
|
||||
const nonce = useNonce();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center select-none py-1 justify-between duration-200 h-17 w-60 rd-2 px-3 transition-all hover:translate-y--1.5"
|
||||
:style="computedBackground"
|
||||
@mouseover="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
>
|
||||
<div class="grid gap-1">
|
||||
<h1 class="text-ellipsis overflow-hidden whitespace-nowrap font-size-4.5 font-800">
|
||||
{{ user?.user?.name }}
|
||||
</h1>
|
||||
<p class="font-bold color-white:70 font-size-3.5">
|
||||
{{ user?.user?.role }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="relative ml2">
|
||||
<NuxtImg
|
||||
v-if="user?.user?.avatar"
|
||||
:nonce="nonce"
|
||||
:src="`${user?.user?.avatar}?size=40`"
|
||||
class="h-auto w-10 min-w-10 rd-100"
|
||||
draggable="false"
|
||||
:alt="`${user?.user?.name}'s avatar`"
|
||||
>
|
||||
<span
|
||||
class="rd-100 absolute block right-0 border-solid bg-green h-2.5 w-2.5 bottom-0 border-1 border-black"
|
||||
/>
|
||||
</nuxtimg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContributorsQuery } from "#gql";
|
||||
|
||||
const { string, contributors } = defineProps<{
|
||||
string: string;
|
||||
contributors: NonNullable<ContributorsQuery["credits"]>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="justify-center my-10">
|
||||
<h1 class="font-extrabold color-primary font-size-10 mb-5">
|
||||
{{ $t(string) }}
|
||||
</h1>
|
||||
<div class="inline-flex flex-wrap gap-3">
|
||||
<ContributorCard
|
||||
v-for="(item, i) in contributors"
|
||||
:key="i"
|
||||
:user="item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,86 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const emit = defineEmits(["continue"]);
|
||||
|
||||
const visible = ref(false);
|
||||
|
||||
const showButton = ref(false);
|
||||
|
||||
let interval: number;
|
||||
|
||||
watch(visible, () => {
|
||||
if (visible.value) {
|
||||
showButton.value = false;
|
||||
interval = window.setTimeout(() => {
|
||||
showButton.value = !showButton.value;
|
||||
}, 10000);
|
||||
}
|
||||
else {
|
||||
window.clearTimeout(interval);
|
||||
}
|
||||
});
|
||||
|
||||
useHead({
|
||||
htmlAttrs: computed(() => ({
|
||||
class: visible.value ? "overflow-hidden" : "",
|
||||
})),
|
||||
});
|
||||
|
||||
function continueButton() {
|
||||
visible.value = false;
|
||||
emit("continue");
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
visible,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div v-if="visible" class="flex items-center justify-center fixed inset-0 bg-black bg-opacity-50 z-9999">
|
||||
<div class="relative bg-gray shadow-lg w-full rounded-lg bg-gray-800 p-6 max-w-md">
|
||||
<button class="cursor-pointer transition-colors absolute bg-transparent border-none top-4 right-4 text-text hover:text-red" @click="visible = false">
|
||||
<FAIcon icon="fa-solid fa-times" class="h-5 w-5" />
|
||||
</button>
|
||||
<div class="text-center">
|
||||
<h1 class="font-extrabold text-2xl text-primary mb-4">
|
||||
{{ $t("component.donationModal.title") }}
|
||||
</h1>
|
||||
<p class="mb-4 text-lg text-gray-300">
|
||||
{{ $t("component.donationModal.description") }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center mb-6">
|
||||
<div class="grid grid-cols-2">
|
||||
<a href="https://www.patreon.com/Timeraa" target="_blank" class="flex items-center justify-center font-bold rounded-full text-white h12.5 bg-orange-500 py-2 px-4 m-2 hover:bg-orange-600 transition duration-300 inline-block">
|
||||
<FAIcon icon="fa-brands fa-patreon" class="mr-2 h5" /> {{ $t("component.donationModal.patreon", { name: "Patreon" }) }}
|
||||
</a>
|
||||
<a href="https://github.com/sponsors/PreMiD" target="_blank" class="h12.5 bg-black font-bold text-white rounded-full py-2 px-4 m-2 transition duration-300 inline-block flex items-center justify-center cursor-pointer hover:bg-op-80">
|
||||
<FAIcon icon="fa-brands fa-github" class="mr-2 h5" /> {{ $t("component.donationModal.github", { name: "GitHub" }) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!showButton" class="text-gray-400">
|
||||
{{ $t("component.donationModal.holdTight") }}
|
||||
</p>
|
||||
<button v-if="showButton" class="cursor-pointer text-white rounded-full py-2 transition duration-300 font-size-4 px-6 bg-primary hover:bg-primary-highlight font-semibold outline-none b-solid b-transparent" @click="continueButton">
|
||||
{{ $t("component.donationModal.continue") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
const { question, answer } = defineProps<{
|
||||
question: string;
|
||||
answer: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="">
|
||||
<h2 class="font-extrabold font-size-5 pb-1">
|
||||
{{ question }}
|
||||
</h2>
|
||||
<p class="font-size-4 c-text lt-sm:font-size-4.5">
|
||||
{{ answer }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,155 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useI18n();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const partners = [
|
||||
{
|
||||
href: "https://crowdin.com",
|
||||
image: "/assets/images/partners/crowdin.svg",
|
||||
label: "Crowdin",
|
||||
},
|
||||
{
|
||||
href: "https://www.atlassian.com/software/statuspage",
|
||||
image: "/assets/images/partners/statuspage.svg",
|
||||
label: "Statuspage",
|
||||
},
|
||||
];
|
||||
|
||||
interface LinkSection {
|
||||
title: string;
|
||||
links: {
|
||||
href: string | any;
|
||||
icon?: string;
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const linkSections = computed(() => [
|
||||
{
|
||||
title: t("footer.followUs"),
|
||||
links: [
|
||||
{
|
||||
href: "https://discord.premid.app",
|
||||
icon: "fa-brands fa-discord",
|
||||
label: "Discord",
|
||||
},
|
||||
{
|
||||
href: "https://github.com/PreMiD/PreMiD",
|
||||
icon: "fa-brands fa-github",
|
||||
label: "GitHub",
|
||||
},
|
||||
{
|
||||
href: "https://x.com/PreMiDapp",
|
||||
icon: "fa-brands fa-x-twitter",
|
||||
label: "Twitter",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("footer.supportUs"),
|
||||
links: [
|
||||
{
|
||||
label: t("footer.supportList.donate"),
|
||||
href: localePath("/donate"),
|
||||
},
|
||||
{
|
||||
label: t("footer.supportList.contribute"),
|
||||
href: "https://github.com/PreMiD/PreMiD",
|
||||
},
|
||||
{
|
||||
label: t("footer.supportList.translate"),
|
||||
href: "https://crowdin.com/project/premid",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("footer.more"),
|
||||
links: [
|
||||
{
|
||||
label: t("footer.moreList.faq"),
|
||||
href: localePath("/downloads#faq"),
|
||||
},
|
||||
{
|
||||
label: t("footer.moreList.documentation"),
|
||||
href: "https://docs.premid.app",
|
||||
},
|
||||
{
|
||||
label: t("footer.moreList.status"),
|
||||
href: "https://status.premid.app",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("footer.legal"),
|
||||
links: [
|
||||
{
|
||||
label: t("footer.legalList.privacyPolicy"),
|
||||
href: localePath("/privacy"),
|
||||
},
|
||||
{
|
||||
label: t("footer.legalList.termsOfService"),
|
||||
href: localePath("/terms"),
|
||||
},
|
||||
{
|
||||
label: t("footer.legalList.cookiePolicy"),
|
||||
href: localePath("/cookie"),
|
||||
},
|
||||
],
|
||||
},
|
||||
] as LinkSection[] satisfies LinkSection[]);
|
||||
|
||||
const nonce = useNonce();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-10">
|
||||
<div class="bg-gray min-h-50">
|
||||
<div class="max-w-screen-lg mx-auto">
|
||||
<div class="flex justify-between flex-wrap mx-5 gap-5 py-5">
|
||||
<div>
|
||||
<h1 class="font-bold font-size-4.5 pb2">
|
||||
{{ t('footer.partners') }}
|
||||
</h1>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<a
|
||||
v-for="partner in partners"
|
||||
:key="partner.href"
|
||||
class="block transition-opacity w-8 max-h-8 opacity-50 hover:opacity-100"
|
||||
:href="partner.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<NuxtImg :src="partner.image" :alt="partner.label" height="32px" width="32px" :nonce="nonce" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="section in linkSections" :key="section.title">
|
||||
<h1 class="font-bold font-size-4.5 pb2">
|
||||
{{ section.title }}
|
||||
</h1>
|
||||
<ol>
|
||||
<li v-for="link in section.links" :key="link.label" class="mb-2">
|
||||
<a :href="link.href" target="_blank" class="flex gap-1 items-center color-#878b99 hover:c-light-9">
|
||||
<FAIcon v-if="link.icon" class="h-5 w-5" :icon="link.icon" />
|
||||
<span>{{ link.label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<div class="justify-center text-center mt-5 pb-5">
|
||||
<span class="items-center inline-flex gap-2">
|
||||
{{ t('footer.withLoveBy') }}
|
||||
<FAIcon class="h-4 w-4 c-red" icon="fa-solid fa-heart" />
|
||||
{{ t('footer.by') }}
|
||||
<a href="https://recodive.com" target="_blank">Recodive</a>
|
||||
</span>
|
||||
<br>
|
||||
<div class="mt2 text-xs color-light-9">
|
||||
{{ t('footer.copyright', { year: "2018", currentYear: new Date().getFullYear(), company: 'Recodive oHG.' }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,142 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
const { t } = useI18n();
|
||||
const links = computed(() => [
|
||||
{
|
||||
to: "/store",
|
||||
icon: "fa-solid fa-cart-arrow-down",
|
||||
name: t("header.links.store"),
|
||||
},
|
||||
{
|
||||
to: "/downloads",
|
||||
icon: "fa-solid fa-download",
|
||||
name: t("header.links.downloads"),
|
||||
},
|
||||
{
|
||||
to: "/contributors",
|
||||
icon: "fa-solid fa-handshake-angle",
|
||||
name: t("header.links.contributors"),
|
||||
},
|
||||
]);
|
||||
|
||||
const navOpen = ref(false);
|
||||
|
||||
const localePath = useLocalePath();
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
class: () => navOpen.value ? "overflow-hidden" : "",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- <PageBanner /> -->
|
||||
<div
|
||||
id="header"
|
||||
class="flex items-center select-none w-full top-0 lg-md:relative lt-md:sticky z-1000 flex-justify-between p-3 bg-bg-primary"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="localePath('/')"
|
||||
class="color-primary font-discord font-size-8"
|
||||
aria-label="Homepage"
|
||||
>
|
||||
PreMiD
|
||||
</NuxtLink>
|
||||
|
||||
<div class="lt-md:hidden">
|
||||
<transition-group name="slide-fade" tag="div">
|
||||
<NuxtLink
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="localePath(link.to)"
|
||||
class="font-size-4.5 color-link-inactive decoration-none mx2 font-900 hover-color-primary"
|
||||
active-class="active"
|
||||
:aria-label="`Link to ${link.name}`"
|
||||
>
|
||||
<span class="items-center gap-2 inline-flex">
|
||||
<span
|
||||
class="inline-flex iconOutline bg-link-icon-bg border-rd-100 p-2"
|
||||
>
|
||||
<FAIcon
|
||||
class="h-4 w-4"
|
||||
:icon="link.icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span class="uppercase">
|
||||
{{ link.name }}
|
||||
</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Nav -->
|
||||
<div class="md:hidden">
|
||||
<button
|
||||
class="md:hidden cursor-pointer b-none bg-transparent c-primary p0 h7"
|
||||
aria-label="Toggle navigation menu"
|
||||
@click="navOpen = !navOpen"
|
||||
>
|
||||
<FAIcon class="hfull" icon="fa-solid fa-bars" />
|
||||
</button>
|
||||
<Transition>
|
||||
<div
|
||||
v-if="navOpen"
|
||||
class="top-0 w-full fixed transition-opacity left-0 p-5 z-50 h-screen bg-black/70 backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex gap-2 flex-col">
|
||||
<button
|
||||
class="b-none bg-transparent p0 fixed hover-color-primary cursor-pointer duration-200 transition-color c-link-inactive h8 top-3 right-3"
|
||||
aria-label="Close navigation menu"
|
||||
@click="navOpen = !navOpen"
|
||||
>
|
||||
<FAIcon class="hfull" icon="fa-solid fa-times" />
|
||||
</button>
|
||||
|
||||
<NuxtLink
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="localePath(link.to)"
|
||||
class="font-size-4.5 color-link-inactive decoration-none mx2 font-900 hover-color-primary"
|
||||
@click="navOpen = false"
|
||||
>
|
||||
<span class="items-center block">
|
||||
<span
|
||||
class="inline-flex bg-link-icon-bg border-rd-100 p-2 mr-2"
|
||||
>
|
||||
<FAIcon
|
||||
class="h-4 w-4"
|
||||
:icon="link.icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span class="uppercase">
|
||||
{{ link.name }}
|
||||
</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: #7289da;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center font-bold w-full h-10 bg-green-500">
|
||||
<p>
|
||||
V2.7.0 is out! <span class="underline">Learn more</span>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
@@ -1,148 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import tinycolor from "tinycolor2";
|
||||
import { ref } from "vue";
|
||||
|
||||
import type { PresencesQuery } from "#gql";
|
||||
import { useExtensionStore } from "~/stores/useExtension";
|
||||
|
||||
const { presence } = defineProps<{
|
||||
presence: PresencesQuery["presences"][number];
|
||||
}>();
|
||||
|
||||
const extension = useExtensionStore();
|
||||
|
||||
const hovered = ref(false);
|
||||
const color = {
|
||||
main: presence.metadata.color,
|
||||
shadow: tinycolor(presence.metadata.color).darken(30).toHexString(),
|
||||
shadowTint: tinycolor(presence.metadata.color).darken(65).toHexString(),
|
||||
text:
|
||||
tinycolor(presence.metadata.color).getLuminance() > 0.95
|
||||
? "#232323"
|
||||
: "#fdfdfd",
|
||||
tint: tinycolor(presence.metadata.color).darken(45).toHexString(),
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const localePath = useLocalePath();
|
||||
function goToPresence() {
|
||||
router.push(localePath(`/store/${presence.metadata.service}`));
|
||||
}
|
||||
|
||||
const hasPresence = computed(() => extension.presences.includes(presence.metadata.service));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="select-none cursor-pointer relative overflow-hidden rounded-lg shadow-md w-20 w-90 h-40"
|
||||
:aria-label="presence.metadata.service"
|
||||
@mouseover="hovered = true"
|
||||
@mouseleave="hovered = false"
|
||||
>
|
||||
<div class="w-full absolute h-full top-0 left-0 z-1" @click="goToPresence" />
|
||||
|
||||
<img
|
||||
format="webp"
|
||||
:draggable="false"
|
||||
class="absolute top-50% left-50% translate--50% opacity-20 bgBounce" :class="[hovered ? 'rotate--10 scale-130' : 'scale-105']"
|
||||
:src="presence.metadata.thumbnail"
|
||||
:alt="presence.metadata.service"
|
||||
width="360px"
|
||||
>
|
||||
|
||||
<div
|
||||
class="rounded-lg flex h-full"
|
||||
:style="`background: linear-gradient(135deg, ${color.main} 0%, ${color.tint} 100%); `"
|
||||
>
|
||||
<img
|
||||
format="webp"
|
||||
draggable="false"
|
||||
class="w-16 h-16 z-20 card-shadow rounded-md my-a mx-7"
|
||||
:src="presence.metadata.logo"
|
||||
:alt="presence.metadata.service"
|
||||
width="64px"
|
||||
height="64px"
|
||||
@click="goToPresence"
|
||||
>
|
||||
<div
|
||||
class="relative my-a z-20 transition-color text-3 mr-4 text-color font-50 w-6/9"
|
||||
>
|
||||
<h1
|
||||
class="card-shadow font-bold overflow-hidden text-ellipsis text-nowrap text-xl"
|
||||
@click="goToPresence"
|
||||
>
|
||||
{{ presence.metadata.service }}
|
||||
</h1>
|
||||
<Transition name="card-animation" mode="out-in">
|
||||
<div :key="`${presence.metadata.service}_desc`">
|
||||
<p v-if="!hovered || !extension.hasExtension" class="card-shadow h-10 font-normal line-clamp-3 line-height-3.5">
|
||||
{{ presence.metadata.description.en }}
|
||||
</p>
|
||||
<div v-else-if="hovered && extension.hasExtension" class="flex gap-2">
|
||||
<button
|
||||
v-if="!hasPresence"
|
||||
class="gap-2 flex items-center justify-center h-10 cursor-pointer font-bold rounded-full transition-colors bg-primary hover:bg-primary-highlight text-white border-none w-min min-w-25"
|
||||
@click="extension.addPresence(presence.metadata.service)"
|
||||
>
|
||||
<FAIcon class="h5 w5" icon="fa-solid fa-plus" />
|
||||
<p>
|
||||
{{ $t("component.storeCard.addPresence") }}
|
||||
</p>
|
||||
</button>
|
||||
<button v-else class="gap-2 flex items-center justify-center h-10 cursor-pointer font-bold rounded-full transition-colors text-white border-none min-w-25 w-min bg-red hover:bg-red-3" @click="extension.removePresence(presence.metadata.service)">
|
||||
<FAIcon class="h5 w5" icon="fa-solid fa-times" />
|
||||
<p>
|
||||
{{ $t("component.storeCard.removePresence") }}
|
||||
</p>
|
||||
</button>
|
||||
<!-- <button
|
||||
class="rounded-full text-white h-10 border-none gap-2 flex items-center justify-center w-10 cursor-pointer transition-colors bg-red hover:bg-red-3"
|
||||
>
|
||||
<FAIcon class="h5 w5" icon="fa-solid fa-heart" />
|
||||
</button> -->
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<div
|
||||
class="text-color absolute flex gap-2 mr-2 right-0 flex-col op-50 shadow-tint mt-2"
|
||||
>
|
||||
<!-- <p>
|
||||
{{ Math.round(presence.users / 5) }}
|
||||
<FAIcon class="h-4 w-4" icon="fa-sold fa-bolt" />
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-shadow {
|
||||
filter: drop-shadow(0 0 0.3rem var(--shadow));
|
||||
}
|
||||
.shadow-tint {
|
||||
filter: drop-shadow(0 0 0.3rem var(--shadowTint));
|
||||
}
|
||||
|
||||
.text-color {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-animation-enter-active {
|
||||
transition: all 200ms cubic-bezier(0.26, 0.08, 0, 0.97);
|
||||
}
|
||||
|
||||
.card-animation-leave-active {
|
||||
transition: all 0ms ease;
|
||||
}
|
||||
|
||||
.card-animation-enter,
|
||||
.card-animation-leave-to {
|
||||
transform: translateY(25%) scaleY(0.85);
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.bgBounce {
|
||||
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
const { id } = defineProps<{ id: string }>();
|
||||
|
||||
const { data, status } = await useAsyncGql({ operation: "fetchUserChip", variables: { id } });
|
||||
|
||||
const userAvatar = computed(() => {
|
||||
const avatar = data.value.credits?.[0]?.user?.avatar;
|
||||
if (status.value !== "success" || !avatar)
|
||||
return;
|
||||
|
||||
return `${avatar}?size=24`;
|
||||
});
|
||||
|
||||
const username = computed(() => {
|
||||
if (status.value !== "success")
|
||||
return;
|
||||
return data.value.credits?.[0]?.user?.name;
|
||||
});
|
||||
|
||||
const userColor = computed(() => {
|
||||
if (status.value !== "success")
|
||||
return "#fff";
|
||||
return data.value.credits?.[0]?.user?.roleColor ?? "#fff";
|
||||
});
|
||||
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const nonce = useNonce();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-bind="$attrs">
|
||||
<NuxtLink :to="localePath(`/users/${id}`)" class="flex items-center gap-2">
|
||||
<template v-if="status === 'success' && username">
|
||||
<NuxtImg
|
||||
class="rounded-full h-6 w-6"
|
||||
:src="userAvatar"
|
||||
alt="User avatar"
|
||||
:nonce="nonce"
|
||||
/>
|
||||
<p
|
||||
class="font-bold" :style="{
|
||||
color: userColor,
|
||||
}"
|
||||
>
|
||||
{{ username }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="status === 'pending'">
|
||||
<p class="c-text">
|
||||
{{ $t("component.userChip.loading") }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="c-text">
|
||||
{{ id }}
|
||||
</p>
|
||||
</template>
|
||||
</NuxtLink>
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,109 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const searchTerm = defineModel<string>();
|
||||
const selectedCategory = defineModel<string>("category");
|
||||
const sortOrder = defineModel<string>("sortOrder");
|
||||
|
||||
//* Nuxt apparently does not like using route query in defaults
|
||||
searchTerm.value = route.query.search?.toString() || "";
|
||||
selectedCategory.value = route.query.category?.toString() || "";
|
||||
sortOrder.value = route.query.sortBy?.toString() || t("component.searchBar.sort.mostUsed");
|
||||
|
||||
const categories = [
|
||||
{ tag: "", text: t("component.searchBar.categories.all"), icon: "fa-solid fa-tag" },
|
||||
{ tag: "anime", text: t("component.searchBar.categories.anime"), icon: "fa-solid fa-film" },
|
||||
{ tag: "games", text: t("component.searchBar.categories.games"), icon: "fa-solid fa-gamepad" },
|
||||
{ tag: "music", text: t("component.searchBar.categories.music"), icon: "fa-solid fa-music" },
|
||||
{ tag: "other", text: t("component.searchBar.categories.other"), icon: "fa-solid fa-link" },
|
||||
{ tag: "socials", text: t("component.searchBar.categories.socials"), icon: "fa-solid fa-lightbulb" },
|
||||
{ tag: "videos", text: t("component.searchBar.categories.videos"), icon: "fa-solid fa-video" },
|
||||
];
|
||||
|
||||
const isDropdownOpen = ref(false);
|
||||
const options = [
|
||||
{ text: t("component.searchBar.sort.mostUsed"), icon: "fa-solid fa-sort-amount-down" },
|
||||
{ text: t("component.searchBar.sort.alphabetical"), icon: "fa-solid fa-sort-alpha-down" },
|
||||
];
|
||||
|
||||
const sortByDropdown = ref<HTMLDivElement>();
|
||||
|
||||
function toggleDropdown() {
|
||||
isDropdownOpen.value = !isDropdownOpen.value;
|
||||
}
|
||||
|
||||
function selectOption(option: string) {
|
||||
sortOrder.value = option;
|
||||
isDropdownOpen.value = false;
|
||||
}
|
||||
|
||||
onClickOutside(sortByDropdown, () => {
|
||||
isDropdownOpen.value = false;
|
||||
});
|
||||
|
||||
function updateQuery() {
|
||||
const search = searchTerm.value === "" ? undefined : searchTerm.value;
|
||||
const category = selectedCategory.value === "" ? undefined : selectedCategory.value;
|
||||
const sortBy = sortOrder.value === "" ? undefined : sortOrder.value;
|
||||
|
||||
router.replace({ query: { ...route.query, search, category, sortBy } });
|
||||
}
|
||||
|
||||
watch([searchTerm, selectedCategory, sortOrder], updateQuery);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-gray rounded p-2">
|
||||
<!-- Search Bar -->
|
||||
<div class="flex items-center gap-2 h-10 relative rounded bg-gray-secondary">
|
||||
<input
|
||||
v-model.trim="searchTerm"
|
||||
autofocus
|
||||
type="text"
|
||||
class="text-white bg-transparent p-2 w-full h-10 rounded b-none outline-none text-sm placeholder:font-bold pl-8"
|
||||
:placeholder="$t('component.searchBar.search')"
|
||||
>
|
||||
<label class="absolute h-10 flex items-center left-2">
|
||||
<FAIcon class="h-4 w-4 text-primary" icon="fa-solid fa-magnifying-glass" />
|
||||
</label>
|
||||
<!-- Sort By Dropdown -->
|
||||
<div ref="sortByDropdown" class="flex items-center gap-2 relative z-50 select-none place-content-end min-w-30 mr-3">
|
||||
<div class="items-center cursor-pointer" @click="toggleDropdown">
|
||||
<p class="text-sm font-bold text-white">
|
||||
<FAIcon class="h-4 w-4 text-white" icon="fa-solid fa-sort-down" />
|
||||
{{ sortOrder }}
|
||||
</p>
|
||||
</div>
|
||||
<transition name="dropdown">
|
||||
<div v-if="isDropdownOpen" class="absolute bg-gray-secondary shadow-lg w-auto mt-1 mt-2.5 top-full rounded-b mr--3">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.text"
|
||||
class="p-2 cursor-pointer flex gap-2 items-center transition-colors hover:bg-gray last:rounded-b"
|
||||
:class="{ 'text-blue-500': sortOrder === option.text }"
|
||||
@click="selectOption(option.text)"
|
||||
>
|
||||
<FAIcon :icon="option.icon" class="h-4 w-4" />
|
||||
{{ option.text }}
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Category Filters -->
|
||||
<div class="flex flex-wrap w-full items-center mt-2">
|
||||
<button
|
||||
v-for="c of categories"
|
||||
:key="c.text"
|
||||
class="flex items-center p-2 rounded-full font-bold border-solid border-1 cursor-pointer h-8 m-1 border-gray-secondary"
|
||||
:class="[c.tag === selectedCategory ? 'bg-primary text-white' : 'bg-transparent text-link-inactive']"
|
||||
@click="selectedCategory = c.tag"
|
||||
>
|
||||
<FAIcon class="h-4 w-4 mr-1" :class="[c.tag !== selectedCategory ? 'text-primary' : 'text-white']" :icon="c.icon" />
|
||||
{{ c.text }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from "#app";
|
||||
|
||||
const { error } = defineProps<{
|
||||
error: NuxtError;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const errorMessage = computed(() => {
|
||||
switch (error.statusCode) {
|
||||
case 404:
|
||||
return t("error.404.message");
|
||||
case 500:
|
||||
return t("error.500.message");
|
||||
default:
|
||||
return t("error.default.message");
|
||||
}
|
||||
});
|
||||
|
||||
const errorTitle = computed(() => {
|
||||
switch (error.statusCode) {
|
||||
case 404:
|
||||
return t("error.404.title");
|
||||
case 500:
|
||||
return t("error.500.title");
|
||||
default:
|
||||
return t("error.default.title");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<div class="max-w-screen-lg mx5">
|
||||
<div class="text-center">
|
||||
<h1 class="font-extrabold text-4xl">
|
||||
{{ errorTitle }}
|
||||
</h1>
|
||||
<p class="text-lg mb-8">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<NuxtLink :to="localePath('/')" class="b-none font-size-4 font-bold px-6 rounded-full shadow-lg transition-colors cursor-pointer bg-white text-black py-3 hover:bg-light-900">
|
||||
{{ $t("error.default.button") }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
13
apps/website/globals.d.ts
vendored
13
apps/website/globals.d.ts
vendored
@@ -1,13 +0,0 @@
|
||||
declare module "*.gql" {
|
||||
import type { DocumentNode } from "graphql";
|
||||
|
||||
const Schema: DocumentNode;
|
||||
export = Schema;
|
||||
}
|
||||
|
||||
declare module "*.graphql" {
|
||||
import type { DocumentNode } from "graphql";
|
||||
|
||||
const Schema: DocumentNode;
|
||||
export = Schema;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { IGraphQLConfig } from "graphql-config";
|
||||
|
||||
const config: IGraphQLConfig = {
|
||||
schema: "https://api.premid.app/v3",
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,3 +0,0 @@
|
||||
export default defineI18nConfig(() => ({
|
||||
fallbackLocale: "en",
|
||||
}));
|
||||
@@ -1,39 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
class: "font-inter",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Header />
|
||||
<div class="justify-center mx5 flex min-h-screen">
|
||||
<!-- <ScriptGoogleAdsense
|
||||
data-ad-client="ca-pub-1575460061917202"
|
||||
data-ad-slot="9125593977"
|
||||
data-ad-format="auto"
|
||||
:data-full-width-responsive="true"
|
||||
>
|
||||
<template #error>
|
||||
{{ $t("layout.ads.error") }}
|
||||
</template>
|
||||
</ScriptGoogleAdsense> -->
|
||||
<div class="max-w-screen-lg mt-5">
|
||||
<slot />
|
||||
</div>
|
||||
<!-- <ScriptGoogleAdsense
|
||||
data-ad-client="ca-pub-1575460061917202"
|
||||
data-ad-slot="5154559370"
|
||||
data-ad-format="auto"
|
||||
:data-full-width-responsive="true"
|
||||
>
|
||||
<template #error>
|
||||
{{ $t("layout.ads.error") }}
|
||||
</template>
|
||||
</ScriptGoogleAdsense> -->
|
||||
</div>
|
||||
<Footer />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,281 +0,0 @@
|
||||
import { defineI18nLocale } from "#i18n";
|
||||
|
||||
export default defineI18nLocale(() => ({
|
||||
layout: {
|
||||
ads: {
|
||||
error: "Please support us by disabling your ad blocker.",
|
||||
},
|
||||
},
|
||||
component: {
|
||||
searchBar: {
|
||||
search: "Search",
|
||||
sortBy: "Sort by",
|
||||
searchPresence: "Search Presence",
|
||||
sort: {
|
||||
mostUsed: "Most Used",
|
||||
alphabetical: "Alphabetical",
|
||||
},
|
||||
categories: {
|
||||
all: "All",
|
||||
anime: "Anime",
|
||||
games: "Games",
|
||||
music: "Music",
|
||||
other: "Other",
|
||||
socials: "Socials",
|
||||
videos: "Videos & Streams",
|
||||
},
|
||||
},
|
||||
browserCard: {
|
||||
wip: "WIP",
|
||||
support: {
|
||||
safari: "We're working on supporting Safari, stay tuned!",
|
||||
},
|
||||
},
|
||||
userChip: {
|
||||
loading: "Loading...",
|
||||
},
|
||||
storeCard: {
|
||||
addPresence: "Add",
|
||||
removePresence: "Remove",
|
||||
},
|
||||
donationModal: {
|
||||
title: "A Quick Favor...",
|
||||
description: "We hope you're gonna love PreMiD! If it brings a smile to your face, why not spread some love back? Our team of volunteers put their hearts into making it awesome just for you!",
|
||||
continue: "Continue",
|
||||
close: "Close",
|
||||
patreon: "Support on {name}",
|
||||
github: "Sponsor on {name}",
|
||||
holdTight: "Hold tight... loading the magic button...",
|
||||
},
|
||||
},
|
||||
header: {
|
||||
links: {
|
||||
contributors: "Contributors",
|
||||
downloads: "Downloads",
|
||||
features: "Features",
|
||||
store: "Store",
|
||||
},
|
||||
},
|
||||
page: {
|
||||
users: {
|
||||
userPage: {
|
||||
title: "Presence Contributions",
|
||||
error: {
|
||||
title: "Error",
|
||||
description: "We're having trouble loading this user... Please try again later.",
|
||||
},
|
||||
},
|
||||
},
|
||||
home: {
|
||||
meta: {
|
||||
title: "Home",
|
||||
},
|
||||
title: "Enhance Your Online Presence With PreMiD",
|
||||
subtitle: "Show your friends what {word} you're enjoying.",
|
||||
words: {
|
||||
music: "Music",
|
||||
videos: "Videos",
|
||||
streams: "Streams",
|
||||
media: "Media",
|
||||
},
|
||||
description: "PreMiD is a simple, powerful tool that allows you to share your current media activity across multiple platforms like YouTube, Disney+, Netflix, and more. Stay connected and let your friends see what you're up to in real-time.",
|
||||
getStarted: "Get Started",
|
||||
sections: {
|
||||
feature: {
|
||||
title: "Why You'll Love PreMiD",
|
||||
feature1: {
|
||||
title: "Privacy Control",
|
||||
description: "Take charge of your privacy settings and decide what activities you share with others. Your data, your rules.",
|
||||
},
|
||||
feature2: {
|
||||
title: "Community Driven",
|
||||
description: "Experience unparalleled support for a multitude of platforms, powered by a passionate and dedicated community.",
|
||||
},
|
||||
feature3: {
|
||||
title: "Customizable Settings",
|
||||
description: "Tailor your PreMiD experience with extensive customization options to suit your preferences and needs.",
|
||||
},
|
||||
feature4: {
|
||||
title: "Easy Setup",
|
||||
description: "Get up and running with PreMiD in no time. Our straightforward setup process ensures a hassle-free start.",
|
||||
},
|
||||
feature5: {
|
||||
title: "Discord ToS Compliant",
|
||||
description: "Fully compliant with Discord's Terms of Service by utilizing official endpoints provided by Discord.",
|
||||
},
|
||||
feature6: {
|
||||
title: "Future Features",
|
||||
description: "Stay tuned for exciting new features and improvements that will enhance your PreMiD experience even further.",
|
||||
},
|
||||
},
|
||||
howItWorks: {
|
||||
title: "How It Works",
|
||||
step1: {
|
||||
title: "Install the Extension",
|
||||
description: "Add PreMiD to your browser.",
|
||||
},
|
||||
step2: {
|
||||
title: "Login with Discord",
|
||||
description: "Connect PreMiD with your Discord account.",
|
||||
},
|
||||
step3: {
|
||||
title: "Add Services",
|
||||
description: "Choose the services you want to display, like YouTube, Disney+, and more.",
|
||||
},
|
||||
step4: {
|
||||
title: "Enjoy",
|
||||
description: "Share your activity and enjoy using PreMiD.",
|
||||
},
|
||||
},
|
||||
callToAction: {
|
||||
title: "Ready To Get Started?",
|
||||
description: "Join the {count} users who are already love PreMiD.",
|
||||
button: "Start Now",
|
||||
},
|
||||
},
|
||||
},
|
||||
contributors: {
|
||||
title: "Contributors",
|
||||
presenceDevelopers: "Presence Developers",
|
||||
staff: "Staff",
|
||||
supporters: "Supporters",
|
||||
translators: "Translators",
|
||||
avatar: {
|
||||
tooltip: "Click to copy {name}'s avatar",
|
||||
},
|
||||
},
|
||||
downloads: {
|
||||
title: "Downloads",
|
||||
steps: {
|
||||
install: "Install Extension",
|
||||
login: "Login with Discord",
|
||||
add: "Add Presences",
|
||||
showoff: "Show off!",
|
||||
},
|
||||
section: {
|
||||
heading: {
|
||||
title: "Time to show off.",
|
||||
description: "Use PreMiD now and show off to your friends what you're doing, maybe you find someone with the same interests.",
|
||||
getStarted: "Get Started",
|
||||
extension: "Extension",
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
your: "Your browser",
|
||||
other: "Other browsers",
|
||||
based: "{browser} based",
|
||||
},
|
||||
mobile: {
|
||||
title: "Bad news!",
|
||||
description: "PreMiD is not available for mobile devices, sorry!",
|
||||
},
|
||||
alphaAccess: {
|
||||
title: "Unlock Exclusive Alpha Access!",
|
||||
description: "Step into the future of PreMiD by becoming a Patron or sponsoring us on GitHub. Your support not only propels our development but also grants you first access to the most innovative features we’re crafting. Experience the cutting-edge of what PreMiD can offer and influence its trajectory with your feedback. It's not just about being first—it's about being part of something bigger.",
|
||||
callToAction: "Learn More & Join the Innovation",
|
||||
},
|
||||
faq: "Frequently Asked Questions",
|
||||
faqs: {
|
||||
q1: {
|
||||
question: "What is PreMiD?",
|
||||
answer: "PreMiD is a simple, configurable utility that allows you to show what you're doing on the web in your Discord activity status.",
|
||||
},
|
||||
q2: {
|
||||
question: "How do I use PreMiD?",
|
||||
answer: "You can use PreMiD by installing the extension and logging in with your Discord account. Once you're logged in, you can add presences to your profile and show off to your friends.",
|
||||
},
|
||||
q3: {
|
||||
question: "Is PreMiD against Discord's ToS?",
|
||||
answer: "No, PreMiD is not against Discord's ToS. PreMiD uses Discord's API (including gated API endpoints provided by Discord) to set your activity. This means that PreMiD is in full compliance with Discord's ToS.",
|
||||
},
|
||||
q4: {
|
||||
question: "What services does PreMiD support?",
|
||||
answer: "PreMiD supports many different services including YouTube, Twitch, and Netflix. The list of supported services is constantly growing. You can view the complete list of Presences on our store page.",
|
||||
},
|
||||
q5: {
|
||||
question: "How can I contribute to PreMiD?",
|
||||
answer: "You can contribute to PreMiD by joining our community on GitHub. You can help by reporting issues, suggesting features, or contributing code.",
|
||||
},
|
||||
q6: {
|
||||
question: "Is PreMiD free to use?",
|
||||
answer: "Yes, PreMiD is free to use. However, we do accept donations through Patreon and GitHub Sponsors to help support the development of the project.",
|
||||
},
|
||||
q7: {
|
||||
question: "What should I do if I encounter an issue with PreMiD?",
|
||||
answer: "If you encounter any issues with PreMiD, you can join our Discord server for support. We also have a troubleshooting guide on our documentation.",
|
||||
},
|
||||
q8: {
|
||||
question: "PreMiD doesn't support xyz, can you add it?",
|
||||
answer: "Our so called Presences are community-driven, we don't have the resources to add every single platform. However, you can add your own Presence by following the instructions on our documentation.",
|
||||
},
|
||||
q9: {
|
||||
question: "How often is PreMiD updated?",
|
||||
answer: "We are a small volunteer-driven project, we aim to update PreMiD as often as possible but we can't promise that we will always be on top of things.",
|
||||
},
|
||||
},
|
||||
},
|
||||
store: {
|
||||
title: "Store",
|
||||
noPresence: "No presence matches your search...",
|
||||
presence: {
|
||||
button: {
|
||||
reportIssue: "Report an Issue",
|
||||
suggestFeature: "Suggest a Feature",
|
||||
viewCode: "View Code",
|
||||
},
|
||||
title: {
|
||||
description: "Description",
|
||||
information: "Information",
|
||||
},
|
||||
informationSection: {
|
||||
contributors: "Contributors:",
|
||||
version: "Version: {version}",
|
||||
users: "Users: {users}",
|
||||
tags: "Tags:",
|
||||
supportedUrls: "Supported URLs:",
|
||||
},
|
||||
},
|
||||
header: { categories: "Categories", search: "Search Presence" },
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
partners: "Partners",
|
||||
followUs: "Follow us",
|
||||
supportUs: "Support us",
|
||||
more: "More",
|
||||
legal: "Legal",
|
||||
supportList: {
|
||||
donate: "Donate",
|
||||
contribute: "Contribute",
|
||||
translate: "Translate",
|
||||
},
|
||||
moreList: {
|
||||
faq: "FAQ",
|
||||
documentation: "Documentation",
|
||||
status: "Status",
|
||||
},
|
||||
legalList: {
|
||||
privacyPolicy: "Privacy Policy",
|
||||
termsOfService: "Terms of Service",
|
||||
cookiePolicy: "Cookie Policy",
|
||||
},
|
||||
withLoveBy: "With",
|
||||
by: "by",
|
||||
copyright: "© {year}-{currentYear} {company} All rights reserved.",
|
||||
},
|
||||
error: {
|
||||
404: {
|
||||
title: "404",
|
||||
message: "The page you're looking for doesn't exist.",
|
||||
},
|
||||
500: {
|
||||
title: "500",
|
||||
message: "Something went wrong on our end.",
|
||||
},
|
||||
default: {
|
||||
title: "Error",
|
||||
message: "Something went wrong on our end.",
|
||||
button: "Go Back",
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -1,11 +0,0 @@
|
||||
import { addComponent, defineNuxtModule } from "@nuxt/kit";
|
||||
|
||||
export default defineNuxtModule({
|
||||
setup() {
|
||||
addComponent({
|
||||
export: "FontAwesomeIcon",
|
||||
filePath: "@fortawesome/vue-fontawesome",
|
||||
name: "FAIcon",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
|
||||
export default defineNuxtConfig({
|
||||
nitro: {
|
||||
compressPublicAssets: true,
|
||||
},
|
||||
security: {
|
||||
rateLimiter: false,
|
||||
sri: false,
|
||||
headers: {
|
||||
//* Nuxt Devtools
|
||||
crossOriginEmbedderPolicy: "unsafe-none",
|
||||
contentSecurityPolicy: {
|
||||
"img-src": ["'self'", "data:", "https:"],
|
||||
"script-src": [
|
||||
"'self'",
|
||||
"https:",
|
||||
"'unsafe-inline'",
|
||||
"'strict-dynamic'",
|
||||
"'nonce-{{nonce}}'",
|
||||
"'unsafe-eval'",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
app: {
|
||||
pageTransition: {
|
||||
name: "page",
|
||||
mode: "out-in",
|
||||
},
|
||||
head: {
|
||||
charset: "utf-8",
|
||||
viewport: "width=device-width, initial-scale=1",
|
||||
titleTemplate: "%s - PreMiD",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
transpile: ["@fortawesome/vue-fontawesome"],
|
||||
},
|
||||
css: [
|
||||
"@/scss/index.scss",
|
||||
"@unocss/reset/normalize.css",
|
||||
"@unocss/reset/sanitize/sanitize.css",
|
||||
"@unocss/reset/sanitize/assets.css",
|
||||
"@unocss/reset/eric-meyer.css",
|
||||
],
|
||||
devtools: { enabled: true },
|
||||
features: {
|
||||
inlineStyles: false,
|
||||
},
|
||||
fonts: {
|
||||
families: [
|
||||
{
|
||||
name: "Discord Font",
|
||||
fallbacks: ["Inter", "sans-serif"],
|
||||
provider: "local",
|
||||
preload: false,
|
||||
src: "/assets/fonts/discord.woff2",
|
||||
},
|
||||
{
|
||||
name: "Inter",
|
||||
fallbacks: ["sans-serif"],
|
||||
provider: "google",
|
||||
weights: [400, 500, 600, 700, 800, 900],
|
||||
},
|
||||
],
|
||||
},
|
||||
i18n: {
|
||||
vueI18n: "i18n.ts",
|
||||
baseUrl: "https://premid.app",
|
||||
defaultLocale: "en",
|
||||
langDir: "locales/",
|
||||
strategy: "prefix_except_default",
|
||||
lazy: true,
|
||||
locales: readdirSync("locales").map(locale => ({
|
||||
code: locale.replace(".ts", ""),
|
||||
file: locale,
|
||||
})),
|
||||
},
|
||||
site: {
|
||||
url: "https://premid.app",
|
||||
name: "PreMiD",
|
||||
description: "PreMiD is a simple, configurable utility that allows you to show what you're doing on the web in your Discord activity status.",
|
||||
defaultLocale: "en",
|
||||
},
|
||||
image: {
|
||||
domains: ["cdn.rcd.gg", "cdn.discordapp.com"],
|
||||
ipx: {
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
},
|
||||
},
|
||||
modules: [
|
||||
"@unocss/nuxt",
|
||||
"@nuxtjs/i18n",
|
||||
"@vueuse/nuxt",
|
||||
"@nuxt/image",
|
||||
"nuxt-graphql-client",
|
||||
"@nuxt/fonts",
|
||||
"@nuxtjs/seo",
|
||||
"floating-vue/nuxt",
|
||||
"@nuxtjs/device",
|
||||
"nuxt-typed-router",
|
||||
"nuxt-security",
|
||||
"@pinia/nuxt",
|
||||
"@nuxt/scripts",
|
||||
],
|
||||
runtimeConfig: {
|
||||
// Use NUXT_ prefixed env vars for nuxt config
|
||||
discord_bot_token: "",
|
||||
public: {
|
||||
GQL_CLIENT_HOST: "https://api.premid.app/v3",
|
||||
GQL_HOST: "https://api.premid.app/v3",
|
||||
},
|
||||
},
|
||||
ogImage: {
|
||||
enabled: false,
|
||||
},
|
||||
compatibilityDate: "2024-07-17",
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"name": "@premid/website",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "MPL-2.0",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discord-user-card/vue": "^0.0.9",
|
||||
"@discordjs/rest": "^2.4.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.8",
|
||||
"@nuxt/fonts": "^0.7.2",
|
||||
"@nuxt/image": "^1.8.0",
|
||||
"@nuxt/kit": "^3.13.1",
|
||||
"@nuxt/scripts": "^0.8.5",
|
||||
"@nuxtjs/device": "^3.2.2",
|
||||
"@nuxtjs/google-fonts": "^3.2.0",
|
||||
"@nuxtjs/i18n": "^8.5.2",
|
||||
"@nuxtjs/seo": "2.0.0-rc.21",
|
||||
"@pinia/nuxt": "^0.5.4",
|
||||
"@rollup/rollup-linux-arm64-gnu": "^4.21.2",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/tinycolor2": "^1.4.6",
|
||||
"@unocss/nuxt": "^0.62.3",
|
||||
"@unocss/reset": "^0.62.3",
|
||||
"@unocss/transformer-directives": "^0.62.3",
|
||||
"@vueuse/core": "^11.0.3",
|
||||
"@vueuse/nuxt": "^11.0.3",
|
||||
"bowser": "^2.11.0",
|
||||
"discord-api-types": "^0.37.100",
|
||||
"floating-vue": "^5.2.2",
|
||||
"lodash": "^4.17.21",
|
||||
"nuxt": "^3.13.1",
|
||||
"nuxt-graphql-client": "^0.2.35",
|
||||
"nuxt-security": "2.0.0-rc.9",
|
||||
"nuxt-typed-router": "^3.6.5",
|
||||
"sass": "^1.78.0",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"vue": "^3.5.3",
|
||||
"vue-router": "^4.4.3",
|
||||
"vue-tsc": "^2.1.6"
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
const { data, error } = await useAsyncGql({ operation: "contributors" });
|
||||
|
||||
const filteredData = computed(() => {
|
||||
return data.value?.credits ?? [];
|
||||
});
|
||||
const staff = computed(() => {
|
||||
return (
|
||||
filteredData.value
|
||||
?.filter(item =>
|
||||
[
|
||||
//* Project Management
|
||||
"673682085608816652",
|
||||
//* Moderator
|
||||
"514546359865442304",
|
||||
//* Support
|
||||
"566417964820070421",
|
||||
].includes(item?.user?.roleId || ""),
|
||||
)
|
||||
.sort(sortContributors) || []
|
||||
);
|
||||
});
|
||||
const supporters = computed(() => {
|
||||
return (
|
||||
filteredData.value
|
||||
?.filter(item =>
|
||||
[
|
||||
//* Contributor
|
||||
"1032759805732978708",
|
||||
//* Supporter
|
||||
"515874214750715904",
|
||||
//* Booster
|
||||
"585532751663333383",
|
||||
//* Donator
|
||||
"502165799172309013",
|
||||
].includes(item?.user?.roleId || ""),
|
||||
)
|
||||
.sort(sortContributors) || []
|
||||
);
|
||||
});
|
||||
const presenceDevelopers = computed(() => {
|
||||
return (
|
||||
filteredData.value
|
||||
?.filter(item =>
|
||||
[
|
||||
//* Presence Developer
|
||||
"606222296016879722",
|
||||
].includes(item?.user?.roleId || ""),
|
||||
)
|
||||
.sort(sortContributors) || []
|
||||
);
|
||||
});
|
||||
const translators = computed(() => {
|
||||
return (
|
||||
filteredData.value
|
||||
?.filter(item =>
|
||||
[
|
||||
//* Proofreader
|
||||
"522755339448483840",
|
||||
//* Translator
|
||||
"502148045991968788",
|
||||
].includes(item?.user?.roleId || ""),
|
||||
)
|
||||
.sort(sortContributors) || []
|
||||
);
|
||||
});
|
||||
|
||||
function sortContributors(
|
||||
a: any,
|
||||
b: any,
|
||||
) {
|
||||
if (a?.user?.rolePosition === b?.user?.rolePosition)
|
||||
return a?.user?.name.localeCompare(b?.user?.name);
|
||||
|
||||
return b?.user?.rolePosition - a?.user?.rolePosition;
|
||||
}
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
useSeoMeta({
|
||||
title: t("page.contributors.title"),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center mx-5">
|
||||
<div class="max-w-400">
|
||||
<div v-if="!error">
|
||||
<ContributorSection
|
||||
v-once
|
||||
string="page.contributors.staff"
|
||||
:contributors="staff"
|
||||
/>
|
||||
<ContributorSection
|
||||
v-once
|
||||
string="page.contributors.supporters"
|
||||
:contributors="supporters"
|
||||
/>
|
||||
<ContributorSection
|
||||
v-once
|
||||
string="page.contributors.presenceDevelopers"
|
||||
:contributors="presenceDevelopers"
|
||||
/>
|
||||
<ContributorSection
|
||||
v-once
|
||||
string="page.contributors.translators"
|
||||
:contributors="translators"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h1 class="color-primary mb-2 font-discord font-size-8">
|
||||
Error
|
||||
</h1>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,148 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import Bowser from "bowser";
|
||||
import type DonationModal from "~/components/DonationModal.vue";
|
||||
|
||||
const { t, tm } = useI18n();
|
||||
const { userAgent, isMobile } = useDevice();
|
||||
|
||||
useSeoMeta({
|
||||
title: t("page.downloads.title"),
|
||||
});
|
||||
|
||||
const steps = [t("page.downloads.steps.install"), t("page.downloads.steps.login"), t("page.downloads.steps.add"), t("page.downloads.steps.showoff")];
|
||||
const browsers = computed(() => [t("page.downloads.browser.based", { browser: "Chromium" }), "Firefox", "Edge", "Safari"]);
|
||||
|
||||
const userBrowser = ref<string>();
|
||||
|
||||
const otherBrowsers = computed(() => browsers.value.filter(browser => browser !== userBrowser.value));
|
||||
|
||||
onMounted(() => {
|
||||
const browser = Bowser.getParser(userAgent).getBrowserName();
|
||||
switch (browser) {
|
||||
case "Safari":
|
||||
userBrowser.value = browsers.value[3];
|
||||
break;
|
||||
case "Edge":
|
||||
userBrowser.value = browsers.value[2];
|
||||
break;
|
||||
case "Firefox":
|
||||
userBrowser.value = browsers.value[1];
|
||||
break;
|
||||
default:
|
||||
userBrowser.value = browsers.value[0];
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const faqs = computed(() => Object.keys(tm("page.downloads.faqs")));
|
||||
|
||||
const donationModal = ref<InstanceType<typeof DonationModal>>();
|
||||
|
||||
const continueToStoreOf = ref<string>();
|
||||
function openModal(browser: string) {
|
||||
continueToStoreOf.value = browser;
|
||||
if (donationModal.value)
|
||||
donationModal.value.visible = true;
|
||||
}
|
||||
|
||||
function goToStore() {
|
||||
switch (continueToStoreOf.value) {
|
||||
case browsers.value[0]:
|
||||
window.open("https://chromewebstore.google.com/detail/premid/agjnjboanicjcpenljmaaigopkgdnihi");
|
||||
break;
|
||||
case browsers.value[1]:
|
||||
window.open("https://dl.premid.app/PreMiD.xpi");
|
||||
break;
|
||||
case browsers.value[2]:
|
||||
window.open("https://microsoftedge.microsoft.com/addons/detail/premid/hkchpjlnddoppadcbefbpgmgaeidkkkm");
|
||||
break;
|
||||
case browsers.value[3]:
|
||||
// TODO: Safari
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<DonationModal ref="donationModal" @continue="goToStore" />
|
||||
<section class="flex justify-center items-center relative mb-10 gap-10 h100 lt-md:flex-col">
|
||||
<div class="max-w-60%">
|
||||
<h1 class="font-extrabold mb-6 font-size-10 c-primary">
|
||||
{{ $t("page.downloads.section.heading.title") }}
|
||||
</h1>
|
||||
<p class="font-semibold font-size-5 lt-sm:font-size-4.5">
|
||||
{{ $t("page.downloads.section.heading.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-extrabold mb-6 c-white font-size-6">
|
||||
{{ $t("page.downloads.section.heading.getStarted") }}
|
||||
</h2>
|
||||
<ol class="grid gap-3 list-inside counter-step">
|
||||
<li v-for="step in steps" :key="step" class="before:inline-block before:text-center before:p1 before:w6 before:h6 before:bg-gradient-to-r before:from-primary before:to-primary-highlight before:rounded-full before:font-bold before:mr2">
|
||||
{{ step }}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex items-center w-full flex-col gap-5">
|
||||
<h1 id="extension" class="c-primary font-size-10 font-extrabold mb-6">
|
||||
{{ $t("page.downloads.section.heading.extension") }}
|
||||
</h1>
|
||||
<!-- User on mobile, not supported error -->
|
||||
<div v-if="isMobile" class="text-white rounded-lg flex flex-col items-center justify-center gap-2 bg-red-500 mb-5 p-5">
|
||||
<h2 class="font-bold font-size-5">
|
||||
{{ $t("page.downloads.mobile.title") }}
|
||||
</h2>
|
||||
<p class="font-bold">
|
||||
{{ $t("page.downloads.mobile.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="userBrowser">
|
||||
<h2 class="font-bold font-size-5">
|
||||
{{ $t("page.downloads.browser.your") }}
|
||||
</h2>
|
||||
<BrowserCard :browser="userBrowser" :highlight="true" @click="openModal(userBrowser)" />
|
||||
</template>
|
||||
<h2 class="font-bold font-size-5">
|
||||
{{ $t("page.downloads.browser.other") }}
|
||||
</h2>
|
||||
<div class="flex gap-5 justify-center flex-wrap">
|
||||
<BrowserCard v-for="browser in otherBrowsers" :key="browser" :browser="browser" @click="openModal(browser)" />
|
||||
</div>
|
||||
</section>
|
||||
<section class="flex flex-col items-center justify-center my-10">
|
||||
<div class="text-white rounded-lg shadow-lg max-w-screen-md bg-gradient-to-r from-primary to-purple-600 p-8">
|
||||
<h2 class="font-extrabold text-white mb-4 text-7">
|
||||
{{ $t("page.downloads.alphaAccess.title") }}
|
||||
</h2>
|
||||
<p class="mb-6 text-4">
|
||||
{{ $t("page.downloads.alphaAccess.description") }}
|
||||
</p>
|
||||
<NuxtLink to="/early-access" class="text-center block py-3 px-4 rounded-full transition duration-300 font-semibold bg-secondary w-fit text-secondary-text hover:bg-opacity-80">
|
||||
{{ $t("page.downloads.alphaAccess.callToAction") }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
<section id="faq" class="flex flex-col gap-5 items-center w-full my-10">
|
||||
<h1 class="c-primary font-size-10 font-extrabold mb-6">
|
||||
{{ $t("page.downloads.faq") }}
|
||||
</h1>
|
||||
<div class="flex flex-wrap gap-7 max-w-80%">
|
||||
<FAQCard v-for="faq in faqs" :key="faq" :question="t(`page.downloads.faqs.${faq}.question`)" :answer="t(`page.downloads.faqs.${faq}.answer`)" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.counter-step {
|
||||
counter-reset: step;
|
||||
|
||||
li::before {
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,111 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
function openAlphaModal() {
|
||||
}
|
||||
function openBetaModal() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<header class="text-center mb-12">
|
||||
<h1 class="font-bold text-primary text-4xl">
|
||||
Join Our Early Access Program
|
||||
</h1>
|
||||
<p class="text-lg mt-4">
|
||||
Be the first to experience the latest features and help shape the future of our software.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Early Access Details Section -->
|
||||
<section class="grid mb-6 grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="p-6 shadow-lg rounded-lg flex flex-col justify-between bg-secondary">
|
||||
<div>
|
||||
<h2 class="font-bold text-3xl text-orange-500 mb-3">
|
||||
Alpha Access
|
||||
</h2>
|
||||
<p class="mb-6">
|
||||
Gain first access to new developments and actively participate in shaping the product's direction.
|
||||
</p>
|
||||
</div>
|
||||
<button class="bg-orange-500 rounded-full cursor-pointer hover:bg-orange-600 text-white px-4 b-solid b-none transition-colors font-bold py-3 text-nowrap w-min rounded" @click="openAlphaModal">
|
||||
Get Alpha Access
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-secondary p-6 shadow-lg rounded-lg flex flex-col justify-between">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold mb-3 text-green-500">
|
||||
Beta Testing
|
||||
</h2>
|
||||
<p class="mb-6">
|
||||
Engage in our beta testing to enhance quality and ensure a stable release.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="cursor-pointer rounded-full text-white py-3 px-4 rounded b-solid b-none transition-colors text-nowrap w-min font-bold bg-green-500 hover:bg-green-600" @click="openBetaModal">
|
||||
Sign Up for Beta
|
||||
</button>
|
||||
<p class="text-neutral">
|
||||
200 spots left
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Installation Steps Section -->
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-bold text-primary mb-4 text-center">
|
||||
Installation Instructions
|
||||
</h2>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="bg-secondary p-6 rounded-lg shadow-lg flex gap-2">
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold text-lg">
|
||||
For Chromium-based Browsers:
|
||||
</h3>
|
||||
<ol class="list-decimal pl-4 line-height-5.5">
|
||||
<li>Download the PreMiD-Chromium.zip file from our beta or alpha builds.</li>
|
||||
<li>Navigate to chrome://extensions/ and activate Developer Mode.</li>
|
||||
<li>Drag and drop the PreMiD-Chromium.zip file onto the extensions page.</li>
|
||||
<li>Remove any previous versions of the PreMiD extension if installed.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<FAIcon icon="fab fa-chrome" class="text-3xl text-gray-800" />
|
||||
<FAIcon icon="fab fa-brave" class="text-3xl text-gray-800" />
|
||||
<FAIcon icon="fab fa-edge" class="text-3xl text-gray-800" />
|
||||
<FAIcon icon="fab fa-opera" class="text-3xl text-gray-800" />
|
||||
</div>
|
||||
|
||||
<div class="bg-secondary p-6 rounded-lg shadow-lg flex gap-2">
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold text-lg">
|
||||
For Firefox:
|
||||
</h3>
|
||||
<ol class="list-decimal pl-4 line-height-5.5">
|
||||
<li>Download the PreMiD-Firefox.zip file from our beta or alpha builds.</li>
|
||||
<li>Open about:debugging#/runtime/this-firefox, then click 'Load Temporary Add-on…'.</li>
|
||||
<li>Select the PreMiD-Firefox.zip file you downloaded.</li>
|
||||
<li>Ensure any previous versions of the PreMiD extension are removed.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<FAIcon icon="fab fa-firefox" class="text-3xl text-gray-800" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Discord Community Section -->
|
||||
<section class="text-center mx-auto max-w-screen-md">
|
||||
<h2 class="text-3xl font-bold text-primary mb-4">
|
||||
Feedback and Support
|
||||
</h2>
|
||||
<p class="text-lg mb-10">
|
||||
Join our Discord community. We have dedicated channels for support, bug reports, feature suggestions, and general questions.
|
||||
</p>
|
||||
<a href="https://discord.premid.app" target="_blank" class="bg-primary hover:bg-primary-highlight text-white font-bold px-4 rounded-full b-solid b-none transition-colors py-4">
|
||||
Join our Discord Community
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,352 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { DiscordUserCard } from "@discord-user-card/vue";
|
||||
import "@discord-user-card/vue/style.css";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
useSeoMeta({ title: t("page.home.meta.title") });
|
||||
|
||||
const words = [
|
||||
t("page.home.words.music"),
|
||||
t("page.home.words.videos"),
|
||||
t("page.home.words.streams"),
|
||||
t("page.home.words.media"),
|
||||
];
|
||||
const currentWord = ref(words[0]);
|
||||
let currentWordIndex = 0;
|
||||
|
||||
let wordInterval: number;
|
||||
const scrollerMouseDown = ref(false);
|
||||
const scroller = ref<HTMLDivElement | null>(null);
|
||||
const scrollerItems = ref<HTMLUListElement | null>(null);
|
||||
onMounted(async () => {
|
||||
wordInterval = window.setInterval(() => {
|
||||
currentWordIndex = (currentWordIndex + 1) % words.length;
|
||||
currentWord.value = words[currentWordIndex];
|
||||
}, 2000);
|
||||
|
||||
// Draggable User Cards
|
||||
let startX: number;
|
||||
let scrollLeft: number;
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
scrollerMouseDown.value = true;
|
||||
startX = e.pageX - (scrollerItems.value?.offsetLeft || 0);
|
||||
scrollLeft = scrollerItems.value?.scrollLeft ?? 0;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
scrollerMouseDown.value = false;
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!scrollerMouseDown.value)
|
||||
return;
|
||||
const x = e.pageX - (scrollerItems.value?.offsetLeft || 0);
|
||||
const walk = x - startX;
|
||||
|
||||
// ensure we loop the cards
|
||||
// TODO: loop by shifting elements from back to front
|
||||
if (scrollerItems.value) {
|
||||
scrollerItems.value.scrollLeft = scrollLeft - walk;
|
||||
const scrollWidth = scrollerItems.value.scrollWidth / 2;
|
||||
if (scrollerItems.value.scrollLeft >= scrollWidth) {
|
||||
scrollerItems.value.scrollLeft -= scrollWidth;
|
||||
}
|
||||
else if (scrollerItems.value.scrollLeft <= 0) {
|
||||
scrollerItems.value.scrollLeft += scrollWidth;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (scroller.value && scrollerItems.value) {
|
||||
scroller.value.addEventListener("pointerdown", handleMouseDown);
|
||||
window.addEventListener("pointerup", handleMouseUp);
|
||||
scroller.value.addEventListener("pointermove", handleMouseMove);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scroller.value && scrollerItems.value) {
|
||||
scroller.value.removeEventListener("pointerdown", handleMouseDown);
|
||||
window.removeEventListener("pointerup", handleMouseUp);
|
||||
scroller.value.removeEventListener("pointermove", handleMouseMove);
|
||||
}
|
||||
clearInterval(wordInterval);
|
||||
});
|
||||
});
|
||||
|
||||
const { data: staffData } = await useFetch("/api/getStaffData");
|
||||
|
||||
const features = ref([
|
||||
{
|
||||
icon: "fas fa-lock",
|
||||
title: t("page.home.sections.feature.feature1.title"),
|
||||
description: t("page.home.sections.feature.feature1.description"),
|
||||
},
|
||||
{
|
||||
icon: "fas fa-users",
|
||||
title: t("page.home.sections.feature.feature2.title"),
|
||||
description: t("page.home.sections.feature.feature2.description"),
|
||||
},
|
||||
{
|
||||
icon: "fas fa-cogs",
|
||||
title: t("page.home.sections.feature.feature3.title"),
|
||||
description: t("page.home.sections.feature.feature3.description"),
|
||||
},
|
||||
{
|
||||
icon: "fas fa-check-circle",
|
||||
title: t("page.home.sections.feature.feature4.title"),
|
||||
description: t("page.home.sections.feature.feature4.description"),
|
||||
},
|
||||
{
|
||||
icon: "fas fa-handshake",
|
||||
title: t("page.home.sections.feature.feature5.title"),
|
||||
description: t("page.home.sections.feature.feature5.description"),
|
||||
},
|
||||
{
|
||||
icon: "fas fa-lightbulb",
|
||||
title: t("page.home.sections.feature.feature6.title"),
|
||||
description: t("page.home.sections.feature.feature6.description"),
|
||||
},
|
||||
]);
|
||||
|
||||
const steps = ref([
|
||||
{
|
||||
icon: "download",
|
||||
title: t("page.home.sections.howItWorks.step1.title"),
|
||||
description: t("page.home.sections.howItWorks.step1.description"),
|
||||
},
|
||||
{
|
||||
icon: "sign-in-alt",
|
||||
title: t("page.home.sections.howItWorks.step2.title"),
|
||||
description: t("page.home.sections.howItWorks.step2.description"),
|
||||
},
|
||||
{
|
||||
icon: "plus",
|
||||
title: t("page.home.sections.howItWorks.step3.title"),
|
||||
description: t("page.home.sections.howItWorks.step3.description"),
|
||||
},
|
||||
{
|
||||
icon: "smile",
|
||||
title: t("page.home.sections.howItWorks.step4.title"),
|
||||
description: t("page.home.sections.howItWorks.step4.description"),
|
||||
},
|
||||
]);
|
||||
|
||||
const { data } = await useAsyncGql("getIndexData");
|
||||
|
||||
const computedUsage = computed(() =>
|
||||
Intl.NumberFormat(locale.value).format(data.value?.usage?.count ?? 0),
|
||||
);
|
||||
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const nonce = useNonce();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero Section -->
|
||||
<section
|
||||
class="flex flex-col items-center justify-center text-white min-h-screen"
|
||||
>
|
||||
<div class="text-center flex flex-col items-center mx-5">
|
||||
<NuxtImg
|
||||
format="webp"
|
||||
src="/assets/images/icon.png"
|
||||
alt="PreMiD Logo"
|
||||
class="mb-2 w-32"
|
||||
width="128px"
|
||||
height="128px"
|
||||
:nonce="nonce"
|
||||
/>
|
||||
<h1 class="font-extrabold mb-4 text-4xl">
|
||||
{{ $t("page.home.title") }}
|
||||
</h1>
|
||||
<i18n-t
|
||||
scope="global"
|
||||
keypath="page.home.subtitle"
|
||||
tag="p"
|
||||
class="text-2xl flex mb-8"
|
||||
>
|
||||
<template #word>
|
||||
<span class="relative flex text-center justify-center mx-2 w-25">
|
||||
<transition-group name="slide" tag="span">
|
||||
<span
|
||||
v-for="word in [currentWord]"
|
||||
:key="word"
|
||||
class="absolute w-25 font-bold left-0 text-gradient"
|
||||
>{{ word }}</span>
|
||||
</transition-group>
|
||||
</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<p class="text-lg mb-8 max-w-2xl">
|
||||
{{ $t("page.home.description") }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
:to="localePath('/downloads')"
|
||||
class="transition-colors text-white font-bold font-size-4 px-6 rounded-full shadow-lg mb-8 bg-gradient-to-r from-primary to-purple-600 py-4 border-transparent transition-transform hover:scale-105"
|
||||
>
|
||||
{{ $t("page.home.getStarted") }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div
|
||||
ref="scroller"
|
||||
class="w-full overflow-hidden relative max-w-screen mt25"
|
||||
>
|
||||
<div ref="scrollerItems" class="overflow-hidden">
|
||||
<ul
|
||||
:class="{ '': !scrollerMouseDown }"
|
||||
class="flex gap-4 flex-nowrap scroller-items animate-duration-50000 animate-iteration-infinite animate-ease-linear w-max"
|
||||
>
|
||||
<ClientOnly v-for="index in Array(4)" :key="index">
|
||||
<DiscordUserCard
|
||||
v-for="(card, i) in staffData"
|
||||
:key="i"
|
||||
:activities="card.activities"
|
||||
:user="card.user"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
class="absolute left-0 bg-gradient-to-r h-full top-0 w-16 fade-left from-#111218 to-transparent"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 right-0 h-full w-16 from-#111218 to-transparent fade-right bg-gradient-to-l"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Unique Feature Section -->
|
||||
<section class="text-white mx5 mt-10 pb-12">
|
||||
<div class="text-center mx-auto container">
|
||||
<h2 class="text-4xl font-extrabold mb-12">
|
||||
{{ $t("page.home.sections.feature.title") }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-12">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature.title"
|
||||
class="p-6 rounded-lg shadow-lg transition hover:scale-105 feature-card bg-background-secondary transform duration-500 hover:translate-y--2"
|
||||
>
|
||||
<div class="mb-4 icon">
|
||||
<FAIcon :icon="feature.icon" class="text-gradient fa-3x" />
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold mb-2">
|
||||
{{ feature.title }}
|
||||
</h3>
|
||||
<p class="text-gray-400">
|
||||
{{ feature.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works Section -->
|
||||
<section class="mx5 py-12 bg-gray-100">
|
||||
<div class="container mx-auto text-center">
|
||||
<h2 class="text-4xl font-extrabold mb-12 text-gray-900">
|
||||
How It Works
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 lg:grid-cols-4">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.title"
|
||||
class="p-6 bg-background-secondary rounded-lg relative transform transition duration-500 hover:translate-y--2 hover:scale-105 shadow-md"
|
||||
>
|
||||
<div
|
||||
class="bg-primary text-white rounded-full flex items-center justify-center mx-auto mb-4 absolute w-10 h-10 top--3 left--3"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div class="icon mb-4">
|
||||
<FAIcon :icon="step.icon" class="fa-3x text-primary" />
|
||||
</div>
|
||||
<h3 class="font-bold mb-2 text-xl">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
<p>{{ step.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Call to Action Section -->
|
||||
<section
|
||||
class="py-12 bg-gradient-to-r from-primary to-purple-600 text-white mx5 rounded"
|
||||
>
|
||||
<div class="container mx-auto text-center">
|
||||
<h2 class="text-4xl font-extrabold mb-6">
|
||||
{{ $t("page.home.sections.callToAction.title") }}
|
||||
</h2>
|
||||
<p class="text-lg mb-6">
|
||||
<i18n-t
|
||||
scope="global"
|
||||
keypath="page.home.sections.callToAction.description"
|
||||
tag="span"
|
||||
class="font-bold"
|
||||
>
|
||||
<template #count>
|
||||
<span class="font-bold">{{ computedUsage }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<NuxtLink
|
||||
:to="localePath('/downloads')"
|
||||
class="b-none font-size-4 font-bold px-6 rounded-full shadow-lg transition-colors cursor-pointer py-3 bg-white text-black hover:bg-light-900"
|
||||
>
|
||||
{{ $t("page.home.sections.callToAction.button") }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* Add styles for the sliding word effect */
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition:
|
||||
transform 0.5s,
|
||||
opacity 0.5s;
|
||||
}
|
||||
.slide-enter-from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide-enter-to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.slide-leave-from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.slide-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(90deg, #7289da, #b3aeff);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(calc(-50% - 0.5rem));
|
||||
}
|
||||
}
|
||||
|
||||
.scroller-items {
|
||||
animation-name: scroll;
|
||||
}
|
||||
</style>
|
||||
@@ -1,215 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Bowser from "bowser";
|
||||
import type { PresenceQuery } from "#gql";
|
||||
import { useExtensionStore } from "~/stores/useExtension";
|
||||
|
||||
const route = useRoute("store-service");
|
||||
const router = useRouter();
|
||||
|
||||
const presence = ref<PresenceQuery["presences"][number]>();
|
||||
|
||||
const { data, error } = await useAsyncGql({ operation: "presence", variables: { service: route.params.service } });
|
||||
|
||||
if (data.value.presences.length === 0) {
|
||||
router.replace("/store");
|
||||
}
|
||||
|
||||
presence.value = data.value.presences[0];
|
||||
|
||||
const presenceLink = computed(() => {
|
||||
const letter = presence.value!.metadata.service.charAt(0).toUpperCase();
|
||||
return `https://github.com/PreMiD/Presences/tree/main/websites/${letter}/${presence.value!.metadata.service}`;
|
||||
});
|
||||
|
||||
const { userAgent } = useDevice();
|
||||
|
||||
const extractedInfo = computed(() => {
|
||||
if (import.meta.server) {
|
||||
return {
|
||||
os: {
|
||||
name: "Unknown",
|
||||
version: "Unknown",
|
||||
},
|
||||
browserName: "Unknown",
|
||||
browserVersion: "Unknown",
|
||||
};
|
||||
}
|
||||
|
||||
const info = Bowser.getParser(userAgent);
|
||||
const os = info.getOS();
|
||||
return {
|
||||
os,
|
||||
browserName: info.getBrowserName(),
|
||||
browserVersion: info.getBrowserVersion(),
|
||||
};
|
||||
});
|
||||
|
||||
const { locale } = useI18n();
|
||||
|
||||
const formattedUsers = computed(() => Intl.NumberFormat(locale.value).format(presence.value?.users ?? 0));
|
||||
|
||||
const extension = useExtensionStore();
|
||||
const hasPresence = computed(() => extension.presences.includes(presence.value?.metadata.service ?? ""));
|
||||
|
||||
useSeoMeta({
|
||||
title: presence.value?.metadata.service,
|
||||
description: presence.value?.metadata.description.en,
|
||||
ogDescription: presence.value?.metadata.description.en,
|
||||
ogTitle: presence.value?.metadata.service,
|
||||
ogImage: {
|
||||
url: presence.value?.metadata.logo,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: presence.value?.metadata.service,
|
||||
},
|
||||
twitterCard: "summary",
|
||||
twitterTitle: presence.value?.metadata.service,
|
||||
twitterDescription: presence.value?.metadata.description.en,
|
||||
twitterImage: presence.value?.metadata.logo,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="presence" class="w-full">
|
||||
<div class="relative overflow-hidden w-full items-center flex rounded justify-between px5 flex-wrap h60 mb10">
|
||||
<img :src="presence.metadata.thumbnail" class="absolute w-full h-auto left-50 translate-x--50 opacity-75" alt="Presence thumbnail" width="1024px">
|
||||
|
||||
<div class="relative flex items-center gap-5 transition-left">
|
||||
<img :src="presence.metadata.logo" class="w-auto h-25" alt="Presence logo" width="100px" height="100px">
|
||||
<h1 class="font-extrabold font-size-6">
|
||||
{{ presence.metadata.service }}
|
||||
</h1>
|
||||
</div>
|
||||
<div v-if="extension.hasExtension" class="z-1 right-20 lt-md:right-5 transition-right">
|
||||
<button v-if="hasPresence" class="bg-red c-white rounded-full font-semibold font-size-5 transition-colors cursor-pointer b-solid b-transparent px-4 py-2 duration-300 hover:bg-red-3" @click="extension.removePresence(presence.metadata.service)">
|
||||
<FAIcon class="h-4 w-4" icon="fa-solid fa-times" />
|
||||
{{ $t("component.storeCard.removePresence") }}
|
||||
</button>
|
||||
<button v-else class="bg-primary c-white rounded-full font-semibold font-size-5 transition-colors cursor-pointer b-solid b-transparent px-4 py-2 duration-300 hover:bg-primary-highlight" @click="extension.addPresence(presence.metadata.service)">
|
||||
<FAIcon class="h-4 w-4" icon="fa-solid fa-plus" />
|
||||
{{ $t("component.storeCard.addPresence") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center gap-5 lt-md:flex-col">
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="rounded bg-gray min-h-30 p5">
|
||||
<h1 class="font-extrabold font-size-6 mb2">
|
||||
{{ $t("page.store.presence.title.description") }}
|
||||
</h1>
|
||||
<p class="c-text">
|
||||
{{ presence?.metadata.description.en }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded bg-gray p5">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<a
|
||||
:href="`https://github.com/PreMiD/Presences/issues/new?template=feature_request.yml&title=Add%20a%20Feature%20to%20${presence?.metadata.service}&body=Description%20of%20the%20feature...&presence_name=${presence?.metadata.service}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded-full transition-colors duration-300 font-semibold bg-orange text-#4e0000 py3 px4 hover:bg-op-60"
|
||||
>
|
||||
<FAIcon class="h-4 w-4" icon="fa-solid fa-lightbulb" />
|
||||
{{ $t("page.store.presence.button.suggestFeature") }}
|
||||
</a>
|
||||
<ClientOnly>
|
||||
<a
|
||||
:href="`https://github.com/PreMiD/Presences/issues/new?template=bug_report.yml&os=${extractedInfo.os.name}%20${extractedInfo.os.version}&browser=${extractedInfo.browserName}%20${extractedInfo.browserVersion}&title=Report%20an%20Issue%20for%20${presence?.metadata.service}&body=Description%20of%20the%20issue...&presence_name=${presence?.metadata.service}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="py3 px4 rounded-full hover:bg-op-60 transition-colors duration-300 font-semibold bg-red-500 text-#420000"
|
||||
>
|
||||
<FAIcon class="h-4 w-4" icon="fa-solid fa-bug" />
|
||||
{{ $t("page.store.presence.button.reportIssue") }}
|
||||
</a>
|
||||
</ClientOnly>
|
||||
<a
|
||||
:href="presenceLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-white py3 px4 rounded-full hover:bg-op-60 transition-colors duration-300 font-semibold bg-gray-secondary"
|
||||
>
|
||||
<FAIcon class="h-4 w-4" icon="fa-brands fa-github" />
|
||||
{{ $t("page.store.presence.button.viewCode") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-gray p5 max-w-70 lt-md:max-w-none">
|
||||
<h1 class="font-extrabold font-size-6 mb2">
|
||||
{{ $t("page.store.presence.title.information") }}
|
||||
</h1>
|
||||
<div class="flex flex-col gap-2 min-w-60">
|
||||
<div>
|
||||
<p class="mb2 c-text font-semibold">
|
||||
<FAIcon class="h-4 w-4" icon="fa-solid fa-handshake-angle" />
|
||||
{{ $t("page.store.presence.informationSection.contributors") }}
|
||||
</p>
|
||||
<UserChip :id="presence?.metadata.author.id" class="mb2 block ml5" />
|
||||
<UserChip v-for="contributor in presence?.metadata.contributors" :id="contributor.id" :key="contributor.id" class="mb2 ml5 block" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb2">
|
||||
<FAIcon class="h-4 w-4" icon="fa-solid fa-tag" />
|
||||
<i18n-t scope="global" keypath="page.store.presence.informationSection.version" tag="span" class="mb2 c-text font-semibold ml1">
|
||||
<template #version>
|
||||
<span class="font-normal">{{ presence?.metadata.version }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb2">
|
||||
<FAIcon class="h-4 w-4" icon="fa-solid fa-cart-arrow-down" />
|
||||
<i18n-t scope="global" keypath="page.store.presence.informationSection.users" tag="span" class="ml1 mb2 c-text font-semibold">
|
||||
<template #users>
|
||||
<span class="font-normal">{{ formattedUsers }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb2 c-text font-semibold">
|
||||
<FAIcon class="h-4 w-4" icon="fa-solid fa-tags" />
|
||||
{{ $t("page.store.presence.informationSection.tags") }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2 ml5 mt2">
|
||||
<span v-for="tag in presence?.metadata.tags" :key="tag" class="bg-gray-secondary rounded-full c-text font-semibold text-3 py1 px2">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb2 font-semibold">
|
||||
<FAIcon class="h-4 w-4" icon="fa-solid fa-link" />
|
||||
{{ $t("page.store.presence.informationSection.supportedUrls") }}
|
||||
</p>
|
||||
<ul class="ml5">
|
||||
<li v-if="typeof presence?.metadata.url === 'string'">
|
||||
{{ presence?.metadata.url }}
|
||||
</li>
|
||||
<li v-for="url in presence?.metadata.url" v-else :key="url" class="mb2">
|
||||
<a :href="`//${url}`" target="_blank" rel="noopener noreferrer" class="c-text hover:underline">{{ url }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <ScriptGoogleAdsense
|
||||
data-ad-client="ca-pub-1575460061917202"
|
||||
data-ad-slot="5541572189"
|
||||
data-ad-format="auto"
|
||||
:data-full-width-responsive="true"
|
||||
>
|
||||
<template #error>
|
||||
{{ $t("layout.ads.error") }}
|
||||
</template>
|
||||
</ScriptGoogleAdsense> -->
|
||||
</div>
|
||||
<div v-else-if="error" class="flex justify-center items-center h-full">
|
||||
{{ error }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,232 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { LocationQuery } from "vue-router";
|
||||
import breakpoints from "~/breakpoints";
|
||||
import { useExtensionStore } from "~/stores/useExtension";
|
||||
|
||||
const extension = useExtensionStore();
|
||||
|
||||
const { t } = useI18n();
|
||||
useSeoMeta({
|
||||
title: t("page.store.title"),
|
||||
});
|
||||
|
||||
const { data } = await useAsyncGql({ operation: "presences" });
|
||||
|
||||
const route = useRoute();
|
||||
const currentPage = ref(Number.parseInt(route.query.page?.toString() || "1"));
|
||||
const searchTerm = ref(route.query.search?.toString() || "");
|
||||
const selectedCategory = ref("");
|
||||
|
||||
const sortBy = ref<string>("");
|
||||
const pageSize = ref(9);
|
||||
const totalPages = computed(() =>
|
||||
Math.ceil(data.value.presences.length / pageSize.value),
|
||||
);
|
||||
|
||||
const presences = computed(() => {
|
||||
const startIndex = (currentPage.value - 1) * pageSize.value;
|
||||
const endIndex = startIndex + pageSize.value;
|
||||
const sortedPresences = (
|
||||
selectedCategory.value
|
||||
? data.value.presences.filter(
|
||||
p => p.metadata.category === selectedCategory.value,
|
||||
)
|
||||
: data.value.presences
|
||||
)
|
||||
.filter(p =>
|
||||
p.metadata.service.toLowerCase().includes(searchTerm.value.toLowerCase()),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (sortBy.value === t("component.searchBar.sort.mostUsed"))
|
||||
return b.users - a.users;
|
||||
else if (sortBy.value === t("component.searchBar.sort.alphabetical"))
|
||||
return a.metadata.service.localeCompare(b.metadata.service);
|
||||
return 0;
|
||||
})
|
||||
.sort(a => (extension.presences.includes(a.metadata.service) ? -1 : 1));
|
||||
|
||||
return {
|
||||
data: sortedPresences.slice(startIndex, endIndex),
|
||||
pageSize,
|
||||
totalItems: sortedPresences.length,
|
||||
};
|
||||
});
|
||||
|
||||
async function handleQuery(query: LocationQuery) {
|
||||
const pageQuery = query.page?.toString() || "1";
|
||||
const parsedPage = Number.parseInt(
|
||||
Number.isNaN(Number(pageQuery)) ? "1" : pageQuery,
|
||||
);
|
||||
currentPage.value = Math.max(1, Math.min(parsedPage, totalPages.value));
|
||||
searchTerm.value = query.search?.toString() || "";
|
||||
selectedCategory.value = query.category?.toString() || "";
|
||||
}
|
||||
|
||||
function resizePageSize() {
|
||||
if (window.innerWidth > Number.parseInt(breakpoints.lg)) {
|
||||
pageSize.value = 9;
|
||||
}
|
||||
else {
|
||||
pageSize.value = 8;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", resizePageSize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", resizePageSize);
|
||||
});
|
||||
|
||||
function getLinkProperties({
|
||||
page = currentPage.value,
|
||||
search = searchTerm.value,
|
||||
category = selectedCategory.value,
|
||||
}) {
|
||||
const query = { category, page, search };
|
||||
return {
|
||||
query: Object.fromEntries(
|
||||
Object.entries(query).filter(([, value]) => value !== ""),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function startPage() {
|
||||
const middleOffset = Math.floor(Math.min(5, totalPages.value) / 2);
|
||||
return currentPage.value >= totalPages.value - middleOffset
|
||||
? Math.max(2, totalPages.value - 5)
|
||||
: Math.max(2, currentPage.value - middleOffset);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
(query) => {
|
||||
handleQuery(query);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
handleQuery(route.query);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<StoreSearchBar v-model:sort-order="sortBy" />
|
||||
<!-- Presences Grid or Empty State -->
|
||||
<div
|
||||
v-if="presences.data.length === 0"
|
||||
class="flex justify-center items-center rounded-lg h-50"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-5 text-lg text-primary-highlight"
|
||||
>
|
||||
<FAIcon :icon="['fa', 'frown']" class="mb-2 text-3xl" />
|
||||
<p>{{ $t("page.store.noPresence") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="presences.data.length > 0"
|
||||
class="items-center mt-5 flex-col flex sm:mt-10 min-h-688px"
|
||||
>
|
||||
<div
|
||||
class="gap-4 grid grid-cols-[fit-content(0%)] sm-md:grid-cols-[repeat(2,fit-content(0%))] lg:grid-cols-[repeat(3,fit-content(0%))] overflow-unset"
|
||||
>
|
||||
<StoreCard
|
||||
v-for="presence in presences.data"
|
||||
:key="presence.metadata.service"
|
||||
:presence="presence"
|
||||
/>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div
|
||||
v-if="presences.data.length > 0"
|
||||
class="flex mt-5 mb-10 flex-wrap justify-center sticky z-40"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="getLinkProperties({ page: 1 })"
|
||||
:replace="true"
|
||||
prefetch
|
||||
class="page-nav-button"
|
||||
:class="{ active: 1 === currentPage }"
|
||||
>
|
||||
1
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-for="i in Math.min(5, totalPages)"
|
||||
v-show="totalPages - startPage() - i > -1"
|
||||
:key="i"
|
||||
prefetch
|
||||
:to="getLinkProperties({ page: startPage() + i - 1 })"
|
||||
class="page-nav-button"
|
||||
:class="{ active: startPage() + i - 1 === currentPage }"
|
||||
>
|
||||
{{ startPage() + i - 1 }}
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="totalPages > 1"
|
||||
:to="getLinkProperties({ page: totalPages })"
|
||||
prefetch
|
||||
:class="{ active: totalPages === currentPage }"
|
||||
class="page-nav-button"
|
||||
>
|
||||
{{ totalPages }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#filters {
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.4rem;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-nav-button {
|
||||
font-size: 1.3rem;
|
||||
height: 3.4rem;
|
||||
width: 3.4rem;
|
||||
}
|
||||
|
||||
.page-nav-button {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
background-color: #2e3242;
|
||||
border-radius: 10vw;
|
||||
border: none;
|
||||
margin: 0.2rem;
|
||||
transition: background-color 150ms;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.page-nav-button:hover {
|
||||
background-color: #373b4f;
|
||||
}
|
||||
.page-nav-button.active {
|
||||
transition: background-color 400ms;
|
||||
background-color: #7289da;
|
||||
}
|
||||
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition:
|
||||
opacity 0.1s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,44 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute("users-id");
|
||||
const router = useRouter();
|
||||
|
||||
const { data, status } = await useAsyncGql("userPage", { id: route.params.id });
|
||||
|
||||
const user = computed(() => data.value.credits?.[0]?.user);
|
||||
const presences = computed(() => [...data.value.authorPresences, ...data.value.contributorPresences]);
|
||||
|
||||
if (status.value === "success" && !user.value) {
|
||||
router.replace("/store");
|
||||
}
|
||||
|
||||
const nonce = useNonce();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-10 w-full">
|
||||
<template v-if="user">
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<NuxtImg :src="`${user?.avatar}?size=1024`" alt="User Avatar" class="w-20 h-20 rounded-full" :nonce="nonce" />
|
||||
<h1 class="font-bold text-xl">
|
||||
{{ user?.name }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-10">
|
||||
<h1 class="font-bold c-primary text-center text-10">
|
||||
{{ $t("page.users.userPage.title") }}
|
||||
</h1>
|
||||
<div class="flex flex-wrap gap-5 justify-center">
|
||||
<StoreCard v-for="presence in presences" :key="presence.metadata.service" :presence="presence" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col justify-center items-center gap-3">
|
||||
<h1 class="font-bold text-xl">
|
||||
{{ $t("page.users.userPage.error.title") }}
|
||||
</h1>
|
||||
<p>{{ $t("page.users.userPage.error.description") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,86 +0,0 @@
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faBrave, faChrome, faDiscord, faEdge, faFirefox, faGithub, faOpera, faPatreon, faSafari, faXTwitter } from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faBars,
|
||||
faBolt,
|
||||
faBug,
|
||||
faCartArrowDown,
|
||||
faCheckCircle,
|
||||
faCogs,
|
||||
faDownload,
|
||||
faFilm,
|
||||
faFrown,
|
||||
faGamepad,
|
||||
faHandshake,
|
||||
faHandshakeAngle,
|
||||
faHeart,
|
||||
faLightbulb,
|
||||
faLink,
|
||||
faLock,
|
||||
faMagnifyingGlass,
|
||||
faMusic,
|
||||
faPlus,
|
||||
faSignInAlt,
|
||||
faSmile,
|
||||
faSortAlphaDown,
|
||||
faSortAmountDown,
|
||||
faSortDown,
|
||||
faSortUp,
|
||||
faStream,
|
||||
faTag,
|
||||
faTags,
|
||||
faTimes,
|
||||
faUser,
|
||||
faUsers,
|
||||
faVideo,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
library.add(
|
||||
faDownload,
|
||||
faSignInAlt,
|
||||
faPlus,
|
||||
faSmile,
|
||||
faLock,
|
||||
faUsers,
|
||||
faCogs,
|
||||
faCheckCircle,
|
||||
faHandshake,
|
||||
faLightbulb,
|
||||
faFrown,
|
||||
faPatreon,
|
||||
faSortAmountDown,
|
||||
faSortAlphaDown,
|
||||
faSortDown,
|
||||
faSortUp,
|
||||
faFilm,
|
||||
faGamepad,
|
||||
faMusic,
|
||||
faVideo,
|
||||
faMagnifyingGlass,
|
||||
faLightbulb,
|
||||
faBug,
|
||||
faTag,
|
||||
faTags,
|
||||
faLink,
|
||||
faHandshakeAngle,
|
||||
faDownload,
|
||||
faCartArrowDown,
|
||||
faStream,
|
||||
faUser,
|
||||
faBolt,
|
||||
faPlus,
|
||||
faHeart,
|
||||
faBars,
|
||||
faTimes,
|
||||
faSafari,
|
||||
faChrome,
|
||||
faFirefox,
|
||||
faEdge,
|
||||
faOpera,
|
||||
faBrave,
|
||||
faDiscord,
|
||||
faGithub,
|
||||
faXTwitter,
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user