add arcade and cab editing

This commit is contained in:
sk1982 2024-03-24 21:47:52 -04:00
parent b66502e9c8
commit 6a3358e03e
83 changed files with 2700 additions and 87 deletions

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321005239-create-uuidv4-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321005239-create-uuidv4-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321005359-create-arcade-ext-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321005359-create-arcade-ext-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321014407-create-user-ext-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321014407-create-user-ext-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321022354-create-user-friends-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321022354-create-user-friends-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321023511-create-teams-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321023511-create-teams-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321023539-add-team-to-user-ext-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240321023539-add-team-to-user-ext-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240322035901-create-arcade-join-keys-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240322035901-create-arcade-join-keys-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240322035910-create-team-join-keys-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240322035910-create-team-join-keys-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1 @@
DROP FUNCTION uuid_v4;

View File

@ -0,0 +1,24 @@
-- https://stackoverflow.com/a/61062917
CREATE FUNCTION uuid_v4()
RETURNS CHAR(36)
BEGIN
-- 1th and 2nd block are made of 6 random bytes
SET @h1 = HEX(RANDOM_BYTES(4));
SET @h2 = HEX(RANDOM_BYTES(2));
-- 3th block will start with a 4 indicating the version, remaining is random
SET @h3 = SUBSTR(HEX(RANDOM_BYTES(2)), 2, 3);
-- 4th block first nibble can only be 8, 9 A or B, remaining is random
SET @h4 = CONCAT(HEX(FLOOR(ASCII(RANDOM_BYTES(1)) / 64)+8),
SUBSTR(HEX(RANDOM_BYTES(2)), 2, 3));
-- 5th block is made of 6 random bytes
SET @h5 = HEX(RANDOM_BYTES(6));
-- Build the complete UUID
RETURN LOWER(CONCAT(
@h1, '-', @h2, '-4', @h3, '-', @h4, '-', @h5
));
END

View File

@ -0,0 +1 @@
DROP TABLE actaeon_arcade_ext;

View File

@ -0,0 +1,11 @@
CREATE TABLE actaeon_arcade_ext (
arcadeId INT NOT NULL,
uuid CHAR(36) NOT NULL,
visibility INT NOT NULL,
joinPrivacy INT NOT NULL,
PRIMARY KEY (arcadeId),
UNIQUE KEY (uuid),
FOREIGN KEY (arcadeId) REFERENCES arcade(id)
ON DELETE CASCADE ON UPDATE CASCADE
);

View File

@ -0,0 +1 @@
DROP TABLE actaeon_user_ext;

View File

@ -0,0 +1,12 @@
CREATE TABLE actaeon_user_ext (
userId INT NOT NULL,
uuid CHAR(36) NOT NULL,
visibility INT NOT NULL,
homepage VARCHAR(64),
PRIMARY KEY (userId),
UNIQUE KEY (uuid),
FOREIGN KEY (userId) REFERENCES aime_user(id)
ON DELETE CASCADE ON UPDATE CASCADE
);

View File

@ -0,0 +1 @@
DROP TABLE actaeon_user_friends;

View File

@ -0,0 +1,10 @@
CREATE TABLE actaeon_user_friends (
user1 INT NOT NULL,
user2 INT NOT NULL,
PRIMARY KEY (user1, user2),
FOREIGN KEY (user1) REFERENCES aime_user(id)
ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (user2) REFERENCES aime_user(id)
ON DELETE CASCADE ON UPDATE CASCADE
);

View File

@ -0,0 +1 @@
DROP TABLE actaeon_teams;

View File

@ -0,0 +1,15 @@
CREATE TABLE actaeon_teams (
uuid CHAR(36),
visibility INT NOT NULL,
joinPrivacy INT NOT NULL,
name VARCHAR(255),
owner INT NOT NULL,
chuniTeam INT NOT NULL,
PRIMARY KEY (uuid),
FOREIGN KEY (chuniTeam) REFERENCES chuni_profile_team(id)
ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (owner) REFERENCES aime_user(id)
ON DELETE CASCADE ON UPDATE CASCADE
);

View File

@ -0,0 +1,3 @@
ALTER TABLE actaeon_user_ext
DROP CONSTRAINT fk_team,
DROP COLUMN team;

View File

@ -0,0 +1,5 @@
ALTER TABLE actaeon_user_ext
ADD COLUMN team CHAR(36),
ADD CONSTRAINT fk_team FOREIGN KEY(team) REFERENCES actaeon_teams(uuid)
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1 @@
DROP TABLE actaeon_arcade_join_keys;

View File

@ -0,0 +1,11 @@
CREATE TABLE actaeon_arcade_join_keys (
id CHAR(10) NOT NULL,
arcadeId INT NOT NULL,
remainingUses INT DEFAULT NULL,
totalUses INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
FOREIGN KEY (arcadeId) REFERENCES arcade(id)
ON DELETE CASCADE ON UPDATE CASCADE
);

View File

@ -0,0 +1 @@
DROP TABLE actaeon_team_join_keys;

View File

@ -0,0 +1,11 @@
CREATE TABLE actaeon_team_join_keys (
id CHAR(10) NOT NULL,
teamId CHAR(36) NOT NULL,
remainingUses INT DEFAULT NULL,
totalUses INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
FOREIGN KEY (teamId) REFERENCES actaeon_teams(uuid)
ON DELETE CASCADE ON UPDATE CASCADE
);

21
package-lock.json generated
View File

@ -22,7 +22,6 @@
"mysql2": "^3.9.2",
"next": "14.1.3",
"next-auth": "^5.0.0-beta.15",
"next-client-cookies": "^1.1.0",
"next-themes": "^0.2.1",
"react": "^18",
"react-day-picker": "^8.10.0",
@ -7753,14 +7752,6 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -8529,18 +8520,6 @@
}
}
},
"node_modules/next-client-cookies": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/next-client-cookies/-/next-client-cookies-1.1.0.tgz",
"integrity": "sha512-e/rqQTXHSFuvUJELMeCDgM7dWW6IUNOGr7noWyRSgE/5l033UaqseDrjShfRZYG45JnrYKoX653OdXTJ8cn+NA==",
"dependencies": {
"js-cookie": "^3.0.5"
},
"peerDependencies": {
"next": ">= 13.0.0",
"react": ">= 16.8.0"
}
},
"node_modules/next-themes": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz",

View File

@ -28,7 +28,6 @@
"mysql2": "^3.9.2",
"next": "14.1.3",
"next-auth": "^5.0.0-beta.15",
"next-client-cookies": "^1.1.0",
"next-themes": "^0.2.1",
"react": "^18",
"react-day-picker": "^8.10.0",

211
src/actions/arcade.ts Normal file
View File

@ -0,0 +1,211 @@
'use server';
import { ActaeonArcadeExt as DBArcadeExt, Arcade as DBArcade } from '@/types/db';
import { Arcade, countryValidator, getArcadePermissions } from '@/data/arcade';
import { db } from '@/db';
import { requireUser } from '@/actions/auth';
import { hasPermission, requireArcadePermission, requirePermission } from '@/helpers/permissions';
import { ArcadePermissions, UserPermissions } from '@/types/permissions';
import { ValidatorMap } from '@/types/validator-map';
import { JoinPrivacy, PRIVACY_VALUES, Visibility, VISIBILITY_VALUES } from '@/types/privacy-visibility';
import { IP_REGEX, TIMEZONE_REGEX } from '@/helpers/validators';
import { notFound, redirect } from 'next/navigation';
import { randomString } from '@/helpers/random';
import crypto from 'crypto';
import type { Entries } from 'type-fest';
export type ArcadeUpdate = Partial<Pick<Arcade, 'visibility' | 'joinPrivacy' | 'name' | 'nickname' | 'country' | 'country_id' |
'state' | 'city' | 'region_id' | 'timezone' | 'ip'>>;
const ARCADE_VALIDATORS: ValidatorMap<ArcadeUpdate> = new Map();
ARCADE_VALIDATORS.set('visibility', val => {
if (!VISIBILITY_VALUES.has(val!))
throw new Error('Invalid visibility value');
});
ARCADE_VALIDATORS.set('joinPrivacy', val => {
if (!PRIVACY_VALUES.has(val!))
throw new Error('Invalid join privacy value');
});
(['name', 'nickname', 'state', 'city'] as const).forEach(v => ARCADE_VALIDATORS.set(v, () => {}));
(['country_id', 'region_id'] as const).forEach(v => ARCADE_VALIDATORS.set(v, val => {
const name = v.split('_')[0];
if (typeof val !== 'number' || !Number.isInteger(val))
throw new Error(`${name[0].toUpperCase()}${name.slice(1)} ID must be a number`);
}));
ARCADE_VALIDATORS.set('timezone', val => {
if (!TIMEZONE_REGEX.test(val ?? ''))
throw new Error('Timezone must be in the format ±##:## or ±####');
});
ARCADE_VALIDATORS.set('ip', val => {
if (!IP_REGEX.test(val ?? ''))
throw new Error('Invalid IP format');
});
ARCADE_VALIDATORS.set('country', countryValidator);
export const updateArcade = async (id: number, update: ArcadeUpdate) => {
const user = await requireUser();
const arcadePermissions = await getArcadePermissions(user, id);
requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.EDITOR);
const arcadeUpdate: Partial<DBArcade> = {};
const arcadeExtUpdate: Partial<DBArcadeExt> = {};
for (let [key, val] of (Object.entries(update) as Entries<ArcadeUpdate>)) {
if (!ARCADE_VALIDATORS.has(key))
return { error: true, message: `Unknown key ${key}` };
if (key === 'name' && !val?.toString()?.trim())
return { error: true, message: `Name is required` };
try {
if (val === undefined) val = null;
if (val !== null)
val = (await ARCADE_VALIDATORS.get(key as keyof ArcadeUpdate)!(val)) ?? val;
} catch (e: any) {
return { error: true, message: e?.message ?? 'Unknown error' };
}
if (key === 'joinPrivacy' || key === 'visibility')
arcadeExtUpdate[key] = val as any;
else
arcadeUpdate[key] = val as any;
}
await db.transaction().execute(async trx => {
if (Object.keys(arcadeUpdate).length)
await trx.updateTable('arcade')
.set(arcadeUpdate)
.where('id', '=', id)
.executeTakeFirst();
if (Object.keys(arcadeExtUpdate).length)
await trx.updateTable('actaeon_arcade_ext')
.set(arcadeExtUpdate)
.where('arcadeId', '=', id)
.executeTakeFirst();
});
};
export const joinPublicArcade = async (arcade: number) => {
const user = await requireUser();
const arcadePrivacy = await db.selectFrom('arcade')
.innerJoin('actaeon_arcade_ext as ext', 'ext.arcadeId', 'arcade.id')
.where('arcade.id', '=', arcade)
.select('ext.joinPrivacy')
.executeTakeFirst();
if (!arcadePrivacy)
return notFound();
if (arcadePrivacy.joinPrivacy !== JoinPrivacy.PUBLIC)
requirePermission(user.permissions, UserPermissions.ACMOD);
await db.insertInto('arcade_owner')
.values({
arcade,
user: user.id,
permissions: 1
})
.executeTakeFirst();
}
export const removeUserFromArcade = async (arcade: number, userId?: number) => {
const user = await requireUser();
userId ??= user.id;
if (user.id !== userId) {
const arcadePermissions = await getArcadePermissions(user, arcade);
if (!hasPermission(arcadePermissions, ArcadePermissions.OWNER))
requirePermission(user.permissions, UserPermissions.USERMOD);
}
await db.deleteFrom('arcade_owner')
.where('arcade', '=', arcade)
.where('user', '=', userId)
.executeTakeFirst();
}
export const createArcadeLink = async (arcade: number, remainingUses: number | null) => {
const user = await requireUser();
const arcadePermissions = await getArcadePermissions(user, arcade);
requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.OWNER);
const id = randomString(10);
await db.insertInto('actaeon_arcade_join_keys')
.values({
id,
arcadeId: arcade,
remainingUses,
totalUses: 0
})
.executeTakeFirst();
return id;
};
export const deleteArcadeLink = async (arcade: number, link: string) => {
const user = await requireUser();
const arcadePermissions = await getArcadePermissions(user, arcade);
requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.OWNER);
await db.deleteFrom('actaeon_arcade_join_keys')
.where('arcadeId', '=', arcade)
.where('id', '=', link)
.executeTakeFirst();
};
export const createArcade = async (name: string) => {
const user = await requireUser({ permission: UserPermissions.ACMOD });
const uuid = crypto.randomUUID();
await db.transaction().execute(async trx => {
const arcade = await trx.insertInto('arcade')
.values({ name })
.executeTakeFirstOrThrow();
await trx.insertInto('actaeon_arcade_ext')
.values({
arcadeId: Number(arcade.insertId!),
uuid,
visibility: Visibility.PRIVATE,
joinPrivacy: JoinPrivacy.INVITE_ONLY
})
.executeTakeFirst();
await trx.insertInto('arcade_owner')
.values({
user: user.id,
arcade: Number(arcade.insertId!),
permissions: (1 << ArcadePermissions.OWNER) | (1 << ArcadePermissions.VIEW)
})
.executeTakeFirst();
});
redirect('/arcade/' + uuid);
};
export const deleteArcade = async (id: number) => {
const user = await requireUser();
const arcadePermissions = await getArcadePermissions(user, id);
requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.OWNER);
await db.deleteFrom('arcade')
.where('id', '=', id)
.executeTakeFirst();
redirect('/arcade');
}
export const setUserArcadePermissions = async ({ arcadeUser, arcade, permissions }: { arcadeUser: number, arcade: number, permissions: number }) => {
const user = await requireUser();
const arcadePermissions = await getArcadePermissions(user, arcade);
if (!hasPermission(user.permissions, UserPermissions.USERMOD))
requirePermission(arcadePermissions, ArcadePermissions.OWNER);
await db.updateTable('arcade_owner')
.set({ permissions })
.where('user', '=', arcadeUser)
.where('arcade', '=', arcade)
.executeTakeFirst();
};

View File

@ -9,6 +9,8 @@ import { db } from '@/db';
import { UserPermissions } from '@/types/permissions';
import { requirePermission } from '@/helpers/permissions';
import { UserPayload } from '@/types/user';
import { sql } from 'kysely';
import { EMAIL_REGEX } from '@/helpers/validators';
export const getUser = async () => {
const session = await auth();
@ -63,8 +65,6 @@ export const logout = async (options: { redirectTo?: string, redirect?: boolean
return signOut(options);
};
const emailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/i;
export const register = async (formData: FormData) => {
const username = formData.get('username')?.toString()?.trim();
const password = formData.get('password')?.toString()?.trim();
@ -86,7 +86,7 @@ export const register = async (formData: FormData) => {
return { error: true, message: 'Password must be at least 8 characters' };
if (!/^\d{20}$/.test(accessCode))
return { error: true, message: 'Invalid access code format' };
if (!emailRegex.test(email))
if (!EMAIL_REGEX.test(email))
return { error: true, message: 'Invalid email' };
const hashedPassword = await bcrypt.hash(password, process.env.BCRYPT_ROUNDS ? parseInt(process.env.BCRYPT_ROUNDS) : 12)
@ -130,5 +130,13 @@ export const register = async (formData: FormData) => {
.set('email', email)
.execute();
await db.insertInto('actaeon_user_ext')
.values({
userId: user.id,
uuid: sql`uuid_v4()`,
visibility: 0
})
.executeTakeFirst();
return { error: false };
};

151
src/actions/machine.ts Normal file
View File

@ -0,0 +1,151 @@
'use server';
import { ValidatorMap } from '@/types/validator-map';
import { db } from '@/db';
import { ArcadeCab, countryValidator, getArcadeCabs, getArcadePermissions } from '@/data/arcade';
import { requireUser } from '@/actions/auth';
import { requireArcadePermission } from '@/helpers/permissions';
import { ArcadePermissions } from '@/types/permissions';
import type { Entries } from 'type-fest';
export type CabUpdate = Omit<ArcadeCab, 'id' | 'arcade'>;
const CAB_VALIDATORS: ValidatorMap<CabUpdate, number | null> = new Map();
CAB_VALIDATORS.set('country', countryValidator);
const booleanValidator = (val: number | null | undefined) => {
val = +(val as any);
if (val !== 0 && val !== 1)
throw new Error('Invalid boolean value');
return val;
}
CAB_VALIDATORS.set('board', async (val, cab) => {
val = val!.toUpperCase().replace(/-/g, '').trim();
if (!/^[A-Z\d]{15}$/.test(val!))
throw new Error('Invalid board number, must be 15 alphanumeric characters');
const existing = await db.selectFrom('machine')
.where(({ eb, and }) => and([
eb('board', '=', val!),
...(cab === null ? [] : [
eb('id', '!=', cab)
])
]))
.select('board')
.executeTakeFirst();
if (existing)
throw new Error('That board is already in use');
return val;
});
const getMachineBySerial = async (serial: string, excludeCab: number | null = null) => {
return db.selectFrom('machine')
.where(({ eb, and }) => and([
eb('serial', 'like', `${serial.slice(0, 11)}%`),
...(excludeCab === null ? [] : [
eb('id', '!=', excludeCab)
])
]))
.select('serial')
.executeTakeFirst();
}
CAB_VALIDATORS.set('serial', async (val, cab) => {
val = val!.toUpperCase().replace(/-/g, '').trim();
// https://gitea.tendokyu.moe/Hay1tsme/artemis/src/commit/87c7c91e3a7158aabfd2b8dbf69d6a5ca0249da1/core/adb_handlers/base.py#L123
if (!/^A\d{2}[EX]\d{2}[A-HJ-NP-Z]\d{8}$/.test(val))
throw new Error('Invalid keychip format');
if (await getMachineBySerial(val, cab))
throw new Error('That serial is already in use');
return val;
});
CAB_VALIDATORS.set('is_cab', booleanValidator);
CAB_VALIDATORS.set('ota_enable', booleanValidator);
CAB_VALIDATORS.set('game', val => {
val = val!.trim().toUpperCase();
// https://gitea.tendokyu.moe/Hay1tsme/artemis/src/commit/87c7c91e3a7158aabfd2b8dbf69d6a5ca0249da1/core/adb_handlers/base.py#L117
if (!/^S[A-Z\d]{3}P?$/.test(val))
throw new Error('Game must be in the format Sxxx or SxxxP');
});
CAB_VALIDATORS.set('timezone', () => {});
CAB_VALIDATORS.set('memo', () => {});
export const deleteMachine = async (arcade: number, machine: number) => {
const user = await requireUser();
const arcadePermissions = await getArcadePermissions(user, arcade);
requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.REGISTRAR);
await db.deleteFrom('machine')
.where('arcade', '=', arcade)
.where('id', '=', machine)
.executeTakeFirst();
};
const validateUpdate = async (update: CabUpdate, cab: number | null) => {
if (!('serial' in update))
return { error: true, message: 'Keychip is required' };
for (let [key, val] of (Object.entries(update) as Entries<CabUpdate>)) {
if (!CAB_VALIDATORS.has(key))
return { error: true, message: `Unknown key ${key}` };
if (key === 'serial' && !val)
return { error: true, message: 'Keychip is required' };
try {
if (val === undefined) val = null;
if (val !== null)
(update as any)[key] = ((await CAB_VALIDATORS.get(key)!(val, cab)) ?? val) as any;
else
(update as any)[key] = null;
} catch (e: any) {
return { error: true, message: e?.message ?? 'Unknown error' };
}
}
}
export const updateMachine = async ({ arcade, machine, update }: { arcade: number, machine: number, update: CabUpdate }) => {
const user = await requireUser();
const arcadePermissions = await getArcadePermissions(user, arcade);
requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.REGISTRAR);
const res = await validateUpdate(update, machine);
if (res?.error) return res;
await db.updateTable('machine')
.where('machine.id', '=', machine)
.where('machine.arcade', '=', arcade)
.set(update)
.executeTakeFirst();
};
export const createMachine = async ({ arcade, update }: { arcade: number, update: CabUpdate }) => {
const user = await requireUser();
const arcadePermissions = await getArcadePermissions(user, arcade);
requireArcadePermission(arcadePermissions, user.permissions, ArcadePermissions.REGISTRAR);
const res = await validateUpdate(update, null);
if (res?.error) return { ...res, data: [] };
await db.insertInto('machine')
.values({
...update,
arcade
})
.executeTakeFirst();
return { error: false, message: '', data: await getArcadeCabs({ arcade, user, permissions: arcadePermissions }) };
}

View File

@ -0,0 +1,58 @@
import { db } from '@/db';
import { InvalidLink } from '@/components/invalid-link';
import { requireUser } from '@/actions/auth';
import { notFound, redirect } from 'next/navigation';
import { JoinSuccess } from '@/components/join-success';
export default async function Join({ params }: { params: { arcadeId: string, join: string }}) {
const user = await requireUser();
if (!params.join)
return (<InvalidLink />);
const joinLink = await db.selectFrom('actaeon_arcade_ext as ext')
.innerJoin('actaeon_arcade_join_keys as key', 'key.arcadeId', 'ext.arcadeId')
.where('ext.uuid', '=', params.arcadeId)
.where('key.id', '=', params.join)
.select(['key.arcadeId', 'key.remainingUses', 'key.totalUses', 'key.id'])
.executeTakeFirst();
if (!joinLink)
return (<InvalidLink />);
const res = await db.selectFrom('arcade_owner')
.where('arcade', '=', joinLink.arcadeId)
.where('user', '=', user.id)
.select('user')
.executeTakeFirst();
if (res)
return redirect(`/arcade/${params.arcadeId}`);
await db.transaction().execute(async trx => {
await trx.insertInto('arcade_owner')
.values({
arcade: joinLink.arcadeId,
user: user.id,
permissions: 1
})
.executeTakeFirst();
if (joinLink.remainingUses !== null && joinLink.remainingUses <= 1)
await trx.deleteFrom('actaeon_arcade_join_keys')
.where('id', '=', joinLink.id)
.executeTakeFirst();
else
await trx.updateTable('actaeon_arcade_join_keys')
.where('id', '=', joinLink.id)
.set({
totalUses: joinLink.totalUses + 1,
...(joinLink.remainingUses ? {
remainingUses: joinLink.remainingUses - 1
} : {})
})
.executeTakeFirst();
});
return (<JoinSuccess href={`/arcade/${params.arcadeId}`} />);
}

View File

@ -0,0 +1,24 @@
import { getUser } from '@/actions/auth';
import { getArcadeCabs, getArcadeInviteLinks, getArcades, getArcadeUsers } from '@/data/arcade';
import { notFound } from 'next/navigation';
import { ArcadeDetail } from '@/components/arcade';
import { PrivateVisibilityError } from '@/components/private-visibility-error';
export default async function ArcadeDetailPage({ params }: { params: { arcadeId: string }}) {
const user = await getUser();
const arcade = (await getArcades({ user, uuids: [params.arcadeId], includeUnlisted: true }))[0];
if (!arcade)
return notFound();
if (!arcade.visible)
return <PrivateVisibilityError />;
const [users, cabs, links] = await Promise.all([
getArcadeUsers({ arcade: arcade.id, permissions: arcade.permissions, user }),
getArcadeCabs({ arcade: arcade.id, permissions: arcade.permissions, user }),
getArcadeInviteLinks({ arcade: arcade.id, permissions: arcade.permissions, user })
]);
return (<ArcadeDetail users={users} arcade={arcade} cabs={cabs} links={links} />)
};

View File

@ -0,0 +1,72 @@
import { Arcade, getArcades } from '@/data/arcade';
import { getUser } from '@/actions/auth';
import { Divider, Tooltip } from '@nextui-org/react';
import { GlobeAltIcon, LinkIcon, LockClosedIcon, UserGroupIcon } from '@heroicons/react/24/outline';
import { Visibility } from '@/types/privacy-visibility';
import Link from 'next/link';
import { CreateArcadeButton } from '@/components/create-arcade-button';
const getLocation = (arcade: Arcade) => {
let out = [arcade.city, arcade.state, arcade.country].filter(x => x).join(', ');
if (arcade.timezone) out += ` (${arcade.timezone})`;
return out;
};
export default async function ArcadePage() {
const user = await getUser();
const arcades = (await getArcades({ user })).filter(a => a.visible);
return (<main className="flex flex-col max-w-5xl mx-auto w-full">
<header className="font-semibold flex items-center text-2xl p-4">
Arcades
<CreateArcadeButton />
</header>
<Divider className="hidden sm:block" />
<section className="w-full px-1 sm:p-0 sm:mt-4 flex flex-col gap-2 mx-auto">
{!arcades.length && <span className="text-gray-500 italic ml-2">No arcades found</span>}
{arcades.map(arcade => <article key={arcade.uuid}
className="flex p-4 bg-content1 rounded gap-2 items-center flex-wrap text-xs sm:text-sm md:text-medium">
{arcade.visibility === Visibility.PUBLIC && <Tooltip content="Public">
<GlobeAltIcon className="h-6" />
</Tooltip>}
{arcade.visibility === Visibility.UNLISTED && <Tooltip content="Unlisted">
<LinkIcon className="h-6" />
</Tooltip>}
{arcade.visibility === Visibility.PRIVATE && <Tooltip content="Private">
<LockClosedIcon className="h-6" />
</Tooltip>}
<header className="text-lg font-semibold">
{arcade.uuid ? <Link href={`/arcade/${arcade.uuid}`}
className="hover:text-secondary transition">{arcade.name}</Link> : arcade.name}
</header>
{getLocation(arcade) && <span className="ml-3">
<span className="font-semibold">Location:&nbsp;</span>
{getLocation(arcade)}
</span>}
{arcade.ip && <span className="ml-3">
<span className="font-semibold">IP:&nbsp;</span>
{arcade.ip}
</span>}
<div className="ml-auto flex flex-wrap">
{!!arcade.machineCount && <span className="mr-3">{arcade.machineCount!.toString()} Machine{Number(arcade.machineCount) > 1 ? 's' : ''}</span>}
<Tooltip content={`${arcade.userCount} User${arcade.userCount === 1 ? '' : 's'}`}>
<div className="flex gap-2 mr-3 items-center">
<UserGroupIcon className="w-6" />
<span>{arcade.userCount?.toString()}</span>
</div>
</Tooltip>
Operated by&nbsp;{arcade.ownerUuid ?
<Link href={`/user/${arcade.ownerUuid}`} className="font-semibold hover:text-secondary transition">
{arcade.ownerUsername}
</Link> : <span className="text-gray-500 italic">anonymous user</span>}
</div>
</article>)}
</section>
</main>);
}

View File

@ -4,7 +4,6 @@ import { ChuniPlaylogCard } from '@/components/chuni/playlog-card';
import { getUserData, getUserRating } from '@/actions/chuni/profile';
import { requireUser } from '@/actions/auth';
import { notFound } from 'next/navigation';
import { ChuniTopRating } from '@/components/chuni/top-rating';
import { ChuniTopRatingSidebar } from '@/components/chuni/top-rating-sidebar';
import { Button } from '@nextui-org/react';
import Link from 'next/link';

View File

@ -1,6 +1,4 @@
import { getMusic } from '@/actions/chuni/music';
import { SelectItem, Slider } from '@nextui-org/react';
import { FilterSorter } from '@/components/filter-sorter';
import { ChuniMusicList } from '@/components/chuni/music-list';

View File

@ -1,4 +1,4 @@
import { Skeleton, Spinner } from '@nextui-org/react';
import { Spinner } from '@nextui-org/react';
export default function Loading() {
return (<div className="w-full h-full flex flex-grow items-center justify-center">

3
src/app/not-found.tsx Normal file
View File

@ -0,0 +1,3 @@
export default function NotFound() {
return (<div className="flex w-full h-full items-center justify-center">Not Found.</div>)
}

View File

@ -3,9 +3,10 @@ import CredentialsProvider from 'next-auth/providers/credentials';
import { db, GeneratedDB } from '@/db';
import bcrypt from 'bcrypt';
import { DBUserPayload } from '@/types/user';
import React from 'react';
import { SelectQueryBuilder } from 'kysely';
import { cache } from 'react';
import { SelectQueryBuilder, sql } from 'kysely';
import { AimeUser } from '@/types/db';
import crypto from 'crypto';
let basePath = process.env.BASE_PATH ?? '';
if (basePath.endsWith('/')) basePath = basePath.slice(0, -1);
@ -17,9 +18,14 @@ const selectUserProps = (builder: SelectQueryBuilder<GeneratedDB & { u: AimeUser
.selectAll()
.as('chuni'),
join => join.onRef('chuni.user', '=', 'u.id'))
.leftJoin('actaeon_user_ext as ext', 'ext.userId', 'u.id')
.select(({ fn }) => [
'u.username', 'u.password', 'u.id', 'u.email', 'u.permissions', 'u.created_date', 'u.last_login_date',
'u.suspend_expire_time',
'ext.uuid',
'ext.visibility',
'ext.homepage',
'ext.team',
fn<boolean>('not isnull', ['chuni.id']).as('chuni')
])
.executeTakeFirst();
@ -49,6 +55,22 @@ const nextAuth = NextAuth({
session({ session, token, user }) {
session.user = { ...session.user, ...(token.user as any) };
return session;
},
async signIn({ user }) {
if ((user as any).visibility === null) {
const uuid = crypto.randomUUID();
await db.insertInto('actaeon_user_ext')
.values({
userId: (user as any).id,
uuid,
visibility: 0
})
.executeTakeFirst();
(user as any).uuid = uuid;
(user as any).visibility = 0;
}
return true;
}
},
providers: [CredentialsProvider({
@ -68,14 +90,14 @@ const nextAuth = NextAuth({
if (!user?.password || !await bcrypt.compare(password.trim(), user.password))
return null;
const { password: _, ...payload } = user satisfies DBUserPayload;
const { password: _, ...payload } = user satisfies { [K in keyof DBUserPayload]: DBUserPayload[K] | null };
return payload as any;
}
})]
});
export const auth = React.cache(nextAuth.auth);
export const auth = cache(nextAuth.auth);
export const {
handlers: { GET, POST },

351
src/components/arcade.tsx Normal file
View File

@ -0,0 +1,351 @@
'use client';
import { Arcade, ArcadeCab, ArcadeLink, ArcadeUser } from '@/data/arcade';
import { JoinPrivacy, Visibility } from '@/types/privacy-visibility';
import { Autocomplete, AutocompleteItem, Button, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Select, SelectItem, Tooltip } from '@nextui-org/react';
import { ChevronDownIcon, GlobeAltIcon, LinkIcon, LockClosedIcon, PencilIcon, PencilSquareIcon, PlusIcon, UserMinusIcon, UserPlusIcon } from '@heroicons/react/24/outline';
import { useRef, useState } from 'react';
import { useUser } from '@/helpers/use-user';
import { hasArcadePermission, hasPermission } from '@/helpers/permissions';
import { ARCADE_PERMISSION_NAMES, ArcadePermissions, UserPermissions } from '@/types/permissions';
import { ALLNET_JAPAN_REGION, WACCA_REGION } from '@/types/region';
import { COUNTRY_CODES } from '@/types/country';
import { ArcadeUpdate, createArcadeLink, deleteArcade, deleteArcadeLink, joinPublicArcade, removeUserFromArcade, setUserArcadePermissions, updateArcade } from '@/actions/arcade';
import { useErrorModal } from '@/components/error-modal';
import { Entries } from 'type-fest';
import { Cab } from '@/components/cab';
import Link from 'next/link';
import { useConfirmModal } from '@/components/confirm-modal';
import { XMarkIcon } from '@heroicons/react/20/solid';
import { JoinLinksModal } from '@/components/join-links-modal';
import { useRouter, useSearchParams } from 'next/navigation';
import { PermissionEditModal, PermissionEditModalUser } from '@/components/permission-edit-modal';
export type ArcadeProps = {
arcade: Arcade,
users: ArcadeUser[],
cabs: ArcadeCab[],
links: ArcadeLink[]
};
const ARCADE_KEYS = ['nickname', 'city', 'state', 'country', 'country_id', 'timezone', 'region_id', 'ip'] as const;
const ARCADE_UPDATE_KEYS = ['visibility', 'joinPrivacy', 'name', 'nickname', 'country', 'country_id',
'state', 'city', 'region_id', 'timezone', 'ip'];
const getArcadeLabel = (k: string) => {
if (k === 'ip') return 'IP';
if (k === 'region_id') return 'Region';
return `${k[0].toUpperCase()}${k.slice(1).replace(/_id$/ig, ' ID')}`;
};
const getArcadeValue = <T extends { country: string | null | undefined, region_id: string | null | undefined }>(arcade: T, k: keyof T) => {
if (k === 'country')
return COUNTRY_CODES.get(arcade.country as any) ?? arcade.country;
if (k === 'region_id')
return ALLNET_JAPAN_REGION.get(+(arcade.region_id as any)) ?? arcade.region_id;
return arcade[k]?.toString();
}
export const ArcadeDetail = ({ arcade: initialArcade, users: initialUsers, cabs: initialCabs, links }: ArcadeProps) => {
const searchParams = useSearchParams();
const [arcade, setArcade] = useState({
...initialArcade, region_id:
initialArcade?.region_id?.toString(),
country_id: initialArcade?.country_id?.toString()
});
const [editing, setEditing] = useState(!!searchParams.get('editing'));
const [regionValue, setRegionValue] = useState('');
const [loading, setLoading] = useState(false);
const [users, setUsers] = useState(initialUsers);
const arcadeRestore = useRef({ ...arcade });
const user = useUser();
const setError = useErrorModal();
const [cabs, setCabs] = useState(initialCabs);
const [creatingNewCab, setCreatingNewCab] = useState(false);
const confirm = useConfirmModal();
const [linksOpen, setLinksOpen] = useState(false);
const [editingUser, setEditingUser] = useState<PermissionEditModalUser | null>(null);
const router = useRouter();
const save = () => {
const arcadeUpdateVals = Object.fromEntries(Object.entries(arcade)
.filter(([k]) => ARCADE_UPDATE_KEYS.includes(k)));
const update: Partial<ArcadeUpdate> = {
...arcadeUpdateVals,
country_id: (arcade.country_id === '' || arcade.country_id === undefined) ? null : +arcade.country_id,
region_id: null
};
const regionText = (arcade.region_id ?? regionValue ?? '')
.replace(/-wacca$/, '').trim();
update.region_id = regionText === '' ? null : +regionText;
(Object.entries(update) as Entries<typeof update>).forEach(([k, v]) => {
if (typeof v === 'string')
(update as any)[k] = v = v.trim();
if (v === '') (update as any)[k] = null;
});
setLoading(true);
updateArcade(initialArcade.id, update)
.then(data => {
if (data?.error)
return setError(data?.message!);
setEditing(false);
})
.finally(() => setLoading(false));
};
const renderEdit = (k: keyof Arcade) => {
const label = getArcadeLabel(k);
if (k === 'country')
return (<Select label={label} key={k} isDisabled={loading}
selectedKeys={new Set(arcade.country ? [arcade.country] : [])}
onSelectionChange={s => typeof s !== 'string' && setArcade(
a => ({ ...a, country: [...s][0]?.toString() ?? null }))}>
{[...COUNTRY_CODES].map(([code, name]) => <SelectItem key={code}>
{name}
</SelectItem>)}
</Select>);
if (k === 'region_id')
return (<Autocomplete label={label} key={k} isDisabled={loading} className="col-span-2 sm:col-span-1"
allowsCustomValue
selectedKey={arcade.region_id?.toString()}
inputValue={arcade.region_id === undefined ? regionValue : undefined}
onInputChange={setRegionValue}
onSelectionChange={s => setArcade(a => ({ ...a, region_id: s?.toString() }))}>
{[...[...ALLNET_JAPAN_REGION].map(
([regionId, name]) => (<AutocompleteItem key={regionId.toString()} textValue={`${name} (${regionId})`}>
{name} ({regionId})
</AutocompleteItem>))]
// looks like allnet -> wacca region id is handled internally by artemis?
// ...[...WACCA_REGION].map(([regionId, name]) => (
// <AutocompleteItem key={`${regionId}-wacca`} textValue={`(WACCA) ${name} (${regionId})`}>
// (WACCA) {name} ({regionId})
// </AutocompleteItem>))]
}
</Autocomplete>);
return (<Input key={k} value={arcade[k]?.toString() ?? ''} label={label} isDisabled={loading}
type={k === 'country_id' ? 'number' : 'text'}
onChange={ev => setArcade(a => ({ ...a, [k]: ev.target.value }))} />);
};
const visibilityIcon = (<>
{arcade.visibility === Visibility.PUBLIC && <Tooltip content="Public">
<GlobeAltIcon className="h-8" />
</Tooltip>}
{arcade.visibility === Visibility.UNLISTED && <Tooltip content="Unlisted">
<LinkIcon className="h-8" />
</Tooltip>}
{arcade.visibility === Visibility.PRIVATE && <Tooltip content="Private">
<LockClosedIcon className="h-8" />
</Tooltip>}</>);
return (<main className="w-full flex flex-col mt-2">
<JoinLinksModal links={links} prefix={`/arcade/${arcade.uuid}/join/`}
onDelete={id => deleteArcadeLink(arcade.id, id)}
onCreate={uses => createArcadeLink(arcade.id, uses)}
open={linksOpen} onClose={() => setLinksOpen(false)} />
<PermissionEditModal user={editingUser}
onClose={() => setEditingUser(null)}
permissions={ARCADE_PERMISSION_NAMES}
displayUpTo={hasPermission(user?.permissions, UserPermissions.ACMOD) ?
ArcadePermissions.OWNER : ArcadePermissions.REGISTRAR}
onEdit={(user, permissions) => {
setUsers(u => u.map(u => u.id === user ? { ...u, permissions } : u));
setUserArcadePermissions({ arcadeUser: user, permissions, arcade: arcade.id });
}} />
<header className="font-bold text-5xl self-center flex gap-3 items-center">
{editing ?
<>
<Dropdown isDisabled={loading}>
<DropdownTrigger>
<Button isIconOnly variant="light" size="lg" className="ml-2 w-20">
{visibilityIcon}
<ChevronDownIcon className="w-7" />
</Button>
</DropdownTrigger>
<DropdownMenu selectionMode="single" selectedKeys={new Set([arcade.visibility.toString()])}
onSelectionChange={s => typeof s !== 'string' && s.size && setArcade(
a => ({ ...a, visibility: +[...s][0] }))}>
<DropdownItem key={Visibility.PRIVATE} description="Visible only to arcade members"
startContent={<LockClosedIcon className="h-6" />}>
Private
</DropdownItem>
<DropdownItem key={Visibility.UNLISTED}
description="Visible to those who have the link to this page"
startContent={<LinkIcon className="h-6" />}>
Unlisted
</DropdownItem>
<DropdownItem key={Visibility.PUBLIC} description="Visible to everyone"
startContent={<GlobeAltIcon className="h-6" />}>
Public
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Input aria-label="Name" size="lg" className="font-normal mr-2" labelPlacement="outside-left" type="text"
isDisabled={loading} isRequired placeholder="Name"
value={arcade.name ?? ''} onChange={ev => setArcade(a => ({ ...a, name: ev.target.value }))}
classNames={{
input: 'text-4xl leading-none',
inputWrapper: 'h-24'
}} />
</> :
<>{visibilityIcon} {arcade.name}</>}
</header>
{editing ? <section
className="grid px-2 sm:px-0 grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 2xl:grid-cols-5 5xl:grid-cols-9 gap-2 flex-wrap mt-3">
{ARCADE_KEYS.map(renderEdit)}
<Select isRequired label="Join Privacy" selectedKeys={new Set([arcade.joinPrivacy.toString()])}
isDisabled={loading}
onSelectionChange={s => typeof s !== 'string' && s.size && setArcade(
a => ({ ...a, joinPrivacy: +[...s][0] }))}>
<SelectItem key={JoinPrivacy.INVITE_ONLY.toString()}>Invite Only</SelectItem>
<SelectItem key={JoinPrivacy.PUBLIC.toString()}>Public</SelectItem>
</Select>
<div
className="col-span-2 sm:col-span-3 xl:col-span-1 5xl:col-span-1 5xl:col-start-5 grid grid-cols-2 gap-2 h-full min-h-10">
<Button variant="light" color="danger" className="h-full" isDisabled={loading} onClick={() => {
setArcade(arcadeRestore.current);
setEditing(false);
}}>Cancel</Button>
<Button color="primary" className="h-full" isDisabled={loading} onClick={save}>Save</Button>
</div>
</section> :
<section className="self-center mt-5 gap-x-8 gap-y-4 items-center justify-center text-xl flex flex-wrap">
{ARCADE_KEYS.map(k =>
arcade[k] && <span key={k}>
<span className="font-semibold">{getArcadeLabel(k)}:</span> {getArcadeValue(arcade, k)}
</span>)
}
<span><span className="font-semibold">Join Privacy: </span>
{arcade.joinPrivacy === JoinPrivacy.INVITE_ONLY ? 'Invite only' : 'Public'}
</span>
{hasArcadePermission(arcade.permissions, user?.permissions, ArcadePermissions.EDITOR) &&
<Tooltip content="Edit arcade settings">
<Button className="" isIconOnly variant="light" radius="full" onPress={() => {
setEditing(true);
arcadeRestore.current = { ...arcade };
}}>
<PencilIcon className="h-1/2" />
</Button>
</Tooltip>
}
</section>}
<Divider className="mt-4" />
<section className="max-w-screen-4xl w-full mx-auto">
<header className="py-4 pl-4 sm:pl-0 flex items-center text-2xl font-semibold">
Machines
{!creatingNewCab &&
<Button className="ml-auto mr-2" isIconOnly size="lg" onPress={() => setCreatingNewCab(true)}>
<PlusIcon className="h-1/2" />
</Button>}
</header>
<section className="px-2 sm:px-0">
{creatingNewCab && <Cab permissions={arcade.permissions}
cab={{ arcade: arcade.id } as any}
creatingNew
onDelete={() => setCreatingNewCab(false)}
onNewData={setCabs} />}
{(cabs.length || creatingNewCab) ? cabs.map(
cab => (<Cab key={cab.id} cab={cab} permissions={arcade.permissions}
onEdit={newCab => setCabs(c => c.map(c => c.id === cab.id ? newCab : c))}
onDelete={() => setCabs(c => c.filter(c => c.id !== cab.id))} />)) :
<span className="italic text-gray-500">This arcade has no machines</span>}
</section>
</section>
<Divider className="mt-4 max-w-screen-4xl w-full mx-auto" />
<section className="max-w-screen-4xl w-full mx-auto">
<header className="py-4 pl-4 sm:pl-0 flex items-center text-2xl font-semibold">
<span className="mr-auto">Users</span>
{arcade.joinPrivacy === JoinPrivacy.PUBLIC && !arcade.permissions && <Tooltip content="Join this arcade">
<Button className="mr-2" isIconOnly size="lg" onPress={() => joinPublicArcade(arcade.id)
.then(() => location.reload())}>
<UserPlusIcon className="h-1/2" />
</Button>
</Tooltip>}
{(hasPermission(arcade.permissions, ArcadePermissions.OWNER) ||
hasPermission(user?.permissions, UserPermissions.OWNER)) && <Tooltip content="Manage invite links">
<Button className="mr-2" isIconOnly size="lg" onPress={() => setLinksOpen(true)}>
<LinkIcon className="h-1/2" />
</Button>
</Tooltip>}
{!!arcade.permissions && !hasPermission(arcade.permissions, ArcadePermissions.OWNER) &&
<Tooltip content={<span className="text-danger">Leave this arcade</span>}>
<Button className="mr-2" isIconOnly size="lg" variant="flat" color="danger" onPress={() => {
confirm('Would you like to leave this arcade?', () => {
removeUserFromArcade(arcade.id).then(() => location.reload());
});
}}>
<UserMinusIcon className="h-1/2" />
</Button>
</Tooltip>}
</header>
{!users.length && <span className="italic text-gray-500">This arcade has no users</span>}
<div className="flex flex-wrap gap-3 px-2 sm:px-0">
{users.map((arcadeUser, index) => (<div key={index}
className="p-3 bg-content1 shadow w-full sm:w-64 max-w-full h-16 overflow-hidden flex items-center rounded-lg gap-1">
{'username' in arcadeUser ?
<Link className="font-semibold underline transition hover:text-secondary mr-auto"
href={`/user/${arcadeUser.uuid}`}>{arcadeUser.username}</Link> :
<span className="text-gray-500 italic mr-auto">Anonymous User</span>}
{(hasPermission(arcade.permissions, ArcadePermissions.OWNER) ||
hasPermission(user?.permissions, UserPermissions.USERMOD)) &&
<Tooltip content="Edit user permissions">
<Button isIconOnly size="sm" onPress={() => setEditingUser(arcadeUser)}>
<PencilSquareIcon className="h-1/2" />
</Button>
</Tooltip>
}
{(hasPermission(arcade.permissions, ArcadePermissions.OWNER) ||
hasPermission(user?.permissions, UserPermissions.USERMOD)) &&
arcadeUser.id !== user?.id &&
<Tooltip content={<span className="text-danger">Kick user</span>}>
<Button isIconOnly color="danger" size="sm" onPress={() => {
confirm('Are you sure you want to kick this user?',
() => removeUserFromArcade(arcade.id, arcadeUser.id)
.then(() => setUsers(u => u.filter(u => u.id !== arcadeUser.id))));
}}>
<XMarkIcon className="h-1/2" />
</Button>
</Tooltip>}
</div>))}
</div>
</section>
<Divider className="mt-4 max-w-screen-4xl w-full mx-auto" />
{hasArcadePermission(arcade.permissions, user?.permissions, ArcadePermissions.OWNER) && <section className="max-w-screen-4xl w-full mx-auto">
<header className="py-4 pl-4 sm:pl-0 flex items-center text-2xl font-semibold">
<span className="mr-auto">Management</span>
</header>
<Button color="danger" onPress={() => {
confirm('Do you want to delete this arcade? This action cannot be undone.', () => {
deleteArcade(arcade.id)
.then(() => { router.push('/arcade'); router.refresh() });
});
}}>
Delete this arcade
</Button>
</section>}
</main>
);
};

221
src/components/cab.tsx Normal file
View File

@ -0,0 +1,221 @@
import { ArcadeCab } from '@/data/arcade';
import { GAME_IDS } from '@/types/game-ids';
import { COUNTRY_CODES } from '@/types/country';
import { useConfirmModal } from '@/components/confirm-modal';
import { useUser } from '@/helpers/use-user';
import { hasArcadePermission } from '@/helpers/permissions';
import { ArcadePermissions } from '@/types/permissions';
import { Autocomplete, AutocompleteItem, Button, Checkbox, Input, Select, SelectItem, Textarea, Tooltip } from '@nextui-org/react';
import { ArrowPathIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
import { createMachine, deleteMachine, updateMachine } from '@/actions/machine';
import { useRef, useState } from 'react';
import { Entries } from 'type-fest';
import { useErrorModal } from '@/components/error-modal';
import { generateRandomKeychip } from '@/helpers/keychip';
type CabProps = {
cab?: ArcadeCab,
permissions: number | null,
onDelete: () => void,
onEdit?: (cab: ArcadeCab) => void,
onNewData?: (cab: ArcadeCab[]) => void,
creatingNew?: boolean
};
const formatSerial = (s: string) => {
s = s.replace(/[-\s]/g, '');
if (s.length < 4) return s;
return `${s.slice(0, 4)}-${s.slice(4)}`;
};
export const Cab = ({ cab: initialCab, permissions, onEdit, onNewData, onDelete, creatingNew }: CabProps) => {
const confirm = useConfirmModal();
const user = useUser();
const [cab, setCab] = useState(initialCab ? {
...initialCab,
serial: initialCab?.serial ? formatSerial(initialCab.serial) : initialCab.serial,
board: initialCab?.board ? formatSerial(initialCab.board) : initialCab.board,
} : ({} as ArcadeCab));
const restoreCab = useRef({ ...cab });
const [editing, setEditing] = useState(false);
const [gameInput, setGameInput] = useState('');
const [loading, setLoading] = useState(false);
const setError = useErrorModal();
const save = () => {
setLoading(true);
const { id, arcade, ...update } = cab;
update.game ??= gameInput;
(Object.entries(update) as Entries<ArcadeCab>).forEach(([k, v]) => {
if (typeof v === 'string')
(update as any)[k] = v = v.trim();
if (v === '') (update as any)[k] = null;
});
if (creatingNew)
return createMachine({ arcade, update })
.then(res => {
if (res?.error)
return setError(res.message);
onNewData?.(res.data)
onDelete?.();
})
.finally(() => setLoading(false))
updateMachine({ arcade, machine: id, update })
.then(res => {
if (res?.error)
return setError(res.message);
setEditing(false);
setCab(c => ({ ...cab, ...update }));
onEdit?.({ ...cab, ...update });
})
.finally(() => setLoading(false))
};
const renderEdit = (k: keyof ArcadeCab) => {
if (k === 'game')
return (<Autocomplete label="Game"
isDisabled={loading}
allowsCustomValue
size="sm"
onInputChange={setGameInput}
inputValue={cab.game === undefined ? gameInput : undefined}
selectedKey={cab.game}
onSelectionChange={s => setCab(c => ({ ...c, game: s?.toString() }))}>
{[...GAME_IDS].map(([id, name]) => (<AutocompleteItem key={id} textValue={`${id} (${name})`}>
{id} ({name})
</AutocompleteItem>))}
</Autocomplete>);
if (k === 'country')
return (<Select label="Country" key={k} isDisabled={loading} size="sm"
selectedKeys={new Set(cab.country ? [cab.country] : [])}
onSelectionChange={s => typeof s !== 'string' && setCab(c => ({ ...c, country: [...s][0]?.toString() ?? null }))}>
{[...COUNTRY_CODES].map(([code, name]) => <SelectItem key={code}>
{name}
</SelectItem>)}
</Select>)
if (k === 'ota_enable' || k === 'is_cab')
return (<Checkbox className="text-nowrap mr-0.5 row-start-4 my-1" size="lg" isSelected={!!cab[k]} isDisabled={loading}
onValueChange={v => setCab(c => ({ ...c, [k]: +v }))}>
{k === 'ota_enable' ? 'OTA Enabled' : 'Is Cab'}
</Checkbox>)
const setFormatSerial = () => setCab(c => c.serial ?
({ ...c, serial: formatSerial(c.serial) }) :
c
);
if (k === 'serial')
return (<div key={k} className="w-full h-full flex rounded-lg overflow-hidden">
<Input isRequired size="sm" isDisabled={loading} label="Keychip" radius="none" className="h-full"
onBlur={setFormatSerial}
onFocus={setFormatSerial}
maxLength={16}
onValueChange={v => setCab(c => ({
...c,
serial: v.toUpperCase()
}))}
value={cab.serial ?? ''} />
<Tooltip content="Generate random keychip">
<Button isIconOnly isDisabled={loading} color="primary" size="lg" radius="none"
onPress={() => setCab(c => ({ ...c, serial: formatSerial(generateRandomKeychip()) }))}>
<ArrowPathIcon className="h-7" />
</Button>
</Tooltip>
</div>)
return (<Input key={k} value={cab[k]?.toString() ?? ''}
size="sm" isDisabled={loading}
onValueChange={val => setCab(c =>
({ ...c, [k]: k === 'board' ? val.toUpperCase() : val }))}
label={`${k[0].toUpperCase()}${k.slice(1)}`} />);
};
if (creatingNew || editing) return (<section
className="p-4 rounded-lg bg-content1 flex flex-col gap-2 mb-2 shadow">
<div className="grid grid-cols-2 gap-2 justify-items-center md:grid-cols-3 lg:flex">
{(['game', 'serial', 'board', 'country', 'timezone', 'ota_enable', 'is_cab'] as const).map(renderEdit)}
</div>
<Textarea label="Comment" placeholder="Enter comment" className="w-full" isDisabled={loading}
value={cab.memo ?? ''} onValueChange={memo => setCab(c => ({ ...c, memo }))} />
<div className="w-full sm:w-auto flex self-end">
<Button className="mr-2 flex-grow" variant="light" color="danger" isDisabled={loading} onPress={() => {
setEditing(false);
setCab(restoreCab.current);
if (creatingNew) onDelete();
}}>
Cancel
</Button>
<Button className="flex-grow" color="primary" isDisabled={loading} onPress={save}>
Save
</Button>
</div>
</section>);
if (!cab) return null;
return (
<section
className="p-4 rounded-lg shadow bg-content1 flex flex-wrap text-xs sm:text-sm md:text-medium gap-x-4 gap-y-2 items-center mb-2">
<header className="text-lg font-semibold">
{cab.game ? <>
{GAME_IDS.has(cab.game) ? `${GAME_IDS.get(cab.game)} (${cab.game})` : cab.game}
</> : 'Unknown Game'}
</header>
{cab.serial && <span>
<span className="font-semibold">Keychip: </span>{formatSerial(cab.serial)}
</span>}
{cab.board && <span>
<span className="font-semibold">Board: </span>{formatSerial(cab.board)}
</span>}
{cab.country && <span>
<span className="font-semibold">Country: </span>{COUNTRY_CODES.get(cab.country) ?? cab.country}
</span>}
{cab.timezone && <span>
<span className="font-semibold">Timezone: </span>{cab.timezone}
</span>}
<span>
<span className="font-semibold">OTA Enabled: </span>{cab.ota_enable ? 'Yes' : 'No'}
</span>
<span>
<span className="font-semibold">Is Cab: </span>{cab.is_cab ? 'Yes' : 'No'}
</span>
{hasArcadePermission(permissions, user?.permissions, ArcadePermissions.REGISTRAR) &&
<div className="ml-auto flex gap-2">
<Tooltip content={<span className="text-danger">Delete this machine</span>}>
<Button isIconOnly color="danger" variant="light" onPress={() =>
confirm('This action cannot be undone.',
() => deleteMachine(cab.arcade, cab.id).then(onDelete))}>
<TrashIcon className="h-1/2" />
</Button>
</Tooltip>
<Tooltip content="Edit">
<Button isIconOnly variant="flat" onPress={() => {
setEditing(true);
restoreCab.current = { ...cab };
}}>
<PencilIcon className="h-1/2" />
</Button>
</Tooltip>
</div>}
{cab.memo && <summary className="block w-full">
<span className="font-semibold">Comment: </span>
<span className="text-xs sm:text-sm">{cab.memo}</span>
</summary>}
</section>
);
};

View File

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import { ReactNode, HTMLAttributes } from 'react';
const BACKGROUNDS = [
['bg-[#02a076]'],
@ -17,7 +17,7 @@ export type ChuniDifficultyContainerProps = {
className?: string,
difficulty: number,
containerClassName?: string
} & React.HTMLAttributes<HTMLDivElement>;
} & HTMLAttributes<HTMLDivElement>;
export const ChuniDifficultyContainer = ({ children, className, difficulty, containerClassName, ...props }: ChuniDifficultyContainerProps) => {
return (<div className={`relative ${className ?? ''}`} {...props}>

View File

@ -1,7 +1,7 @@
'use client';
import { addFavoriteMusic, ChuniMusic, getMusic, removeFavoriteMusic } from '@/actions/chuni/music';
import { ChuniPlaylog, getPlaylog } from '@/actions/chuni/playlog';
import { addFavoriteMusic, ChuniMusic, removeFavoriteMusic } from '@/actions/chuni/music';
import { ChuniPlaylog } from '@/actions/chuni/playlog';
import { MusicPlayer } from '@/components/music-player';
import { getJacketUrl, getMusicUrl } from '@/helpers/assets';
import { Ticker } from '@/components/ticker';
@ -9,7 +9,7 @@ import { ChuniMusicPlaylog } from '@/components/chuni/music-playlog';
import { Button } from '@nextui-org/react';
import { HeartIcon as SolidHeartIcon } from '@heroicons/react/24/solid';
import { HeartIcon as OutlineHeartIcon } from '@heroicons/react/24/outline';
import React, { useState } from 'react';
import { useState } from 'react';
import { useErrorModal } from '@/components/error-modal';
type ChuniMusicDetailProps = {

View File

@ -1,10 +1,9 @@
'use client'
import { FilterSorter, Sorter } from '@/components/filter-sorter';
import { WindowScroller, AutoSizer, List } from 'react-virtualized';
import { Button, SelectItem } from '@nextui-org/react';
import { Button } from '@nextui-org/react';
import { addFavoriteMusic, ChuniMusic, removeFavoriteMusic } from '@/actions/chuni/music';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
import { getJacketUrl } from '@/helpers/assets';
import { ChuniLevelBadge } from '@/components/chuni/level-badge';

View File

@ -1,14 +1,13 @@
'use client';
import { ChuniMusic, getMusic } from '@/actions/chuni/music';
import { ChuniPlaylog, getPlaylog } from '@/actions/chuni/playlog';
import { ChuniMusic } from '@/actions/chuni/music';
import { ChuniPlaylog } from '@/actions/chuni/playlog';
import { Accordion, AccordionItem } from '@nextui-org/react';
import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties';
import { ChuniLevelBadge } from '@/components/chuni/level-badge';
import { ChuniRating } from '@/components/chuni/rating';
import { ChuniLampComboBadge, ChuniLampSuccessBadge, ChuniScoreBadge, getVariantFromLamp, getVariantFromRank } from '@/components/chuni/score-badge';
import { ChuniLampComboBadge, ChuniLampSuccessBadge, ChuniScoreBadge, getVariantFromRank } from '@/components/chuni/score-badge';
import { CHUNI_SCORE_RANKS } from '@/helpers/chuni/score-ranks';
import { CHUNI_LAMPS } from '@/helpers/chuni/lamps';
import { ChuniPlaylogCard } from '@/components/chuni/playlog-card';
import { useState } from 'react';

View File

@ -1,6 +1,6 @@
'use client';
import { ChuniPlaylog, getPlaylog } from '@/actions/chuni/playlog';
import { ChuniPlaylog } from '@/actions/chuni/playlog';
import { getJacketUrl } from '@/helpers/assets';
import Link from 'next/link';
import { ChuniRating } from '@/components/chuni/rating';

View File

@ -1,9 +1,8 @@
'use client';
import { CHUNI_FILTER_DIFFICULTY, CHUNI_FILTER_FAVORITE, CHUNI_FILTER_GENRE, CHUNI_FILTER_LAMP, CHUNI_FILTER_LEVEL, CHUNI_FILTER_RATING, CHUNI_FILTER_SCORE, CHUNI_FILTER_WORLDS_END_STARS, CHUNI_FILTER_WORLDS_END_TAG, getLevelValFromStop } from '@/helpers/chuni/filter';
import { CHUNI_FILTER_DIFFICULTY, CHUNI_FILTER_GENRE, CHUNI_FILTER_LAMP, CHUNI_FILTER_LEVEL, CHUNI_FILTER_RATING, CHUNI_FILTER_SCORE, CHUNI_FILTER_WORLDS_END_STARS, CHUNI_FILTER_WORLDS_END_TAG, getLevelValFromStop } from '@/helpers/chuni/filter';
import { FilterField, FilterSorter } from '@/components/filter-sorter';
import { SelectItem } from '@nextui-org/react';
import React, { useState } from 'react';
import { ChuniMusic } from '@/actions/chuni/music';
import { ArrayIndices } from 'type-fest';
import { ChuniPlaylog, getPlaylog } from '@/actions/chuni/playlog';

View File

@ -1,6 +1,6 @@
'use client';
import { ChuniTopRating, ChuniTopRatingProps } from '@/components/chuni/top-rating';
import { ChuniTopRating } from '@/components/chuni/top-rating';
import { getUserRating } from '@/actions/chuni/profile';
import { useState } from 'react';
import { Button, ButtonGroup } from '@nextui-org/react';

View File

@ -2,7 +2,7 @@ import { getUserRating } from '@/actions/chuni/profile';
import { getJacketUrl } from '@/helpers/assets';
import { ChuniRating } from '@/components/chuni/rating';
import { floorToDp } from '@/helpers/floor-dp';
import { ChuniScoreBadge, getVariantFromRank, getVariantFromScore } from '@/components/chuni/score-badge';
import { ChuniScoreBadge, getVariantFromScore } from '@/components/chuni/score-badge';
import { ChuniDifficultyContainer } from '@/components/chuni/difficulty-container';
import { Tooltip } from '@nextui-org/react';
import { ChuniLevelBadge } from '@/components/chuni/level-badge';

View File

@ -1,9 +1,9 @@
'use client';
import { ChuniUserData, getUserData, ProfileUpdate, updateProfile } from '@/actions/chuni/profile';
import { ChuniUserData, ProfileUpdate, updateProfile } from '@/actions/chuni/profile';
import { UserboxItems } from '@/actions/chuni/userbox';
import { ChuniNameplate } from '@/components/chuni/nameplate';
import { avatar, Button, ButtonGroup, Checkbox, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Select, SelectItem, user } from '@nextui-org/react';
import { Button, Checkbox, Divider, Select, SelectItem } from '@nextui-org/react';
import { SelectModalButton } from '@/components/select-modal';
import { ChuniTrophy } from '@/components/chuni/trophy';
import { getAudioUrl, getImageUrl } from '@/helpers/assets';
@ -13,7 +13,6 @@ import { CHUNI_VOICE_LINES } from '@/helpers/chuni/voice';
import { PlayIcon, StopIcon } from '@heroicons/react/24/solid';
import { SaveIcon } from '@/components/save-icon';
import { useAudio } from '@/helpers/use-audio';
import { useIsMounted } from 'usehooks-ts';
import { Entries } from 'type-fest';
import { useErrorModal } from '@/components/error-modal';

View File

@ -4,13 +4,19 @@ import { ReactNode } from 'react';
import { NextUIProvider } from '@nextui-org/react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { ErrorProvider } from '@/components/error-modal';
import { ConfirmProvider } from '@/components/confirm-modal';
import { PromptProvider } from '@/components/prompt-modal';
export function ClientProviders({ children }: { children: ReactNode }) {
return (<ErrorProvider>
<NextUIProvider className="h-full flex">
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem>
{children}
</NextThemesProvider>
</NextUIProvider>
<ConfirmProvider>
<PromptProvider>
<NextUIProvider className="h-full flex">
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem>
{children}
</NextThemesProvider>
</NextUIProvider>
</PromptProvider>
</ConfirmProvider>
</ErrorProvider>);
}

View File

@ -0,0 +1,61 @@
import { createContext, ReactNode, useCallback, useContext, useRef, useState } from 'react';
import { Button, Modal, ModalContent, ModalHeader } from '@nextui-org/react';
import { ModalBody, ModalFooter } from '@nextui-org/modal';
import { useHashNavigation } from '@/helpers/use-hash-navigation';
type ConfirmCallback = (message: string, onConfirm: () => void, onCancel?: () => void) => void;
const ConfirmContext = createContext<ConfirmCallback>(() => {});
export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
const [message, setMessage] = useState<string | null>(null);
const confirmCallback = useRef<() => void>();
const cancelCallback = useRef<() => void>();
const setConfirm: ConfirmCallback = useCallback((message, onConfirm, onCancel) => {
setMessage(message);
confirmCallback.current = onConfirm;
cancelCallback.current = onCancel;
}, []);
const close = () => {
setMessage(null);
confirmCallback.current = undefined;
cancelCallback.current = undefined;
};
const onModalClose = useHashNavigation({
onClose: close,
isOpen: message !== null,
hash: '#confirm'
});
return (<>
<Modal isOpen={message !== null} onClose={onModalClose}>
<ModalContent>
{onClose => <>
<ModalHeader className="text-danger">Are you sure?</ModalHeader>
<ModalBody>{message}</ModalBody>
<ModalFooter className="gap-2">
<Button onPress={() => {
cancelCallback.current?.();
onClose();
}} >
Cancel
</Button>
<Button onPress={() => {
confirmCallback.current?.();
onClose();
}} color="danger">
Confirm
</Button>
</ModalFooter>
</>}
</ModalContent>
</Modal>
<ConfirmContext.Provider value={setConfirm}>
{children}
</ConfirmContext.Provider>
</>);
}
export const useConfirmModal = () => useContext(ConfirmContext);

View File

@ -0,0 +1,31 @@
'use client';
import { Button, Tooltip } from '@nextui-org/react';
import { createArcade } from '@/actions/arcade';
import { PlusIcon } from '@heroicons/react/24/outline';
import { useUser } from '@/helpers/use-user';
import { hasPermission } from '@/helpers/permissions';
import { UserPermissions } from '@/types/permissions';
import { usePromptModal } from '@/components/prompt-modal';
import { useErrorModal } from '@/components/error-modal';
export const CreateArcadeButton = () => {
const user = useUser();
const prompt = usePromptModal();
const setError = useErrorModal();
if (!hasPermission(user?.permissions, UserPermissions.ACMOD))
return null;
return (<Tooltip content="Create new arcade">
<Button isIconOnly className="ml-auto" onPress={() => prompt({
title: 'Enter name', message: 'Enter a name for this arcade',
label: 'Name'
}, val => {
if (!val)
return setError('Name is required');
createArcade(val);
})}>
<PlusIcon className="h-3/4" />
</Button>
</Tooltip>);
}

View File

@ -3,14 +3,21 @@
import { createContext, ReactNode, useContext, useState } from 'react';
import { Button, Modal, ModalContent, ModalHeader } from '@nextui-org/react';
import { ModalBody, ModalFooter } from '@nextui-org/modal';
import { useHashNavigation } from '@/helpers/use-hash-navigation';
const ErrorContext = createContext<(err: string) => void>(() => {});
export const ErrorProvider = ({ children }: { children: ReactNode }) => {
const [error, setError] = useState<string | null>(null);
const onModalClose = useHashNavigation({
onClose: () => setError(null),
isOpen: !!error,
hash: '#error'
});
return (<>
<Modal isOpen={!!error} onClose={() => setError(null)}>
<Modal isOpen={!!error} onClose={onModalClose}>
<ModalContent>
{onClose => <>
<ModalHeader className="text-danger">

View File

@ -1,24 +1,22 @@
'use client';
import { Accordion, AccordionItem, Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Pagination, Select, SelectItem, Slider, Spinner, Switch, Tooltip } from '@nextui-org/react';
import React, { ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Awaitable } from '@auth/core/types';
import { ComponentProps, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { XMarkIcon } from '@heroicons/react/16/solid';
import { ArrowLongUpIcon } from '@heroicons/react/24/solid';
import { useDebounceCallback, useIsMounted } from 'usehooks-ts';
import { usePathname } from 'next/navigation';
import { SearchIcon } from '@nextui-org/shared-icons';
import { DateSelect } from '@/components/date-select';
import { ArrayIndices } from 'type-fest';
import internal from 'stream';
import { useBreakpoint } from '@/helpers/use-breakpoint';
import { Awaitable } from '@/types/awaitable';
type ValueType = {
slider: [number, number],
select: Set<string>,
switch: boolean,
dateSelect: React.ComponentProps<typeof DateSelect>['range']
dateSelect: ComponentProps<typeof DateSelect>['range']
};
type FilterTypes = {
@ -34,7 +32,7 @@ export type FilterField<D, T extends keyof FilterTypes, N extends string> = {
label: string,
filter?: (val: any, data: D) => boolean,
value: ValueType[T],
props?: React.ComponentProps<FilterTypes[T]>,
props?: ComponentProps<FilterTypes[T]>,
className?: string
};
@ -51,7 +49,7 @@ type FilterSorterProps<D, M extends string, N extends string, S extends string>
readonly sorters: Readonly<Sorter<S, D>[]>,
displayModes?: { name: M, icon: ReactNode }[],
searcher?: (search: string, data: D) => boolean | undefined,
children: (displayMode: M, data: D[]) => React.ReactNode,
children: (displayMode: M, data: D[]) => ReactNode,
defaultAscending?: boolean
} & ({
filterers: Filterers<D, N>,

View File

@ -7,7 +7,7 @@ import Link from 'next/link';
import { ThemeSwitcherDropdown, ThemeSwitcherSwitch } from '@/components/theme-switcher';
import { AdjustmentsHorizontalIcon } from '@heroicons/react/24/solid';
import { login, logout } from '@/actions/auth';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { usePathname, useSearchParams } from 'next/navigation';
import { UserPayload } from '@/types/user';
import { MAIN_ROUTES, ROUTES, UserOnly } from '@/routes';
import { useUser } from '@/helpers/use-user';

View File

@ -0,0 +1,9 @@
import { TfiUnlink } from 'react-icons/tfi';
export const InvalidLink = () => {
return (<main className="flex flex-col w-full m-auto items-center gap-4 pb-10 text-center">
<TfiUnlink className="w-48 h-48 mb-10" />
<header className="text-2xl font-semibold">The link you requested is invalid.</header>
<span>It may have expired, or you may have entered it incorrectly.</span>
</main>);
};

View File

@ -0,0 +1,102 @@
import { useEffect, useRef, useState, Fragment } from 'react';
import { Button, Divider, Input, Modal, ModalContent, ModalHeader, Tooltip } from '@nextui-org/react';
import { ModalBody, ModalFooter } from '@nextui-org/modal';
import Link from 'next/link';
import { ClipboardDocumentIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/navigation';
import { useHashNavigation } from '@/helpers/use-hash-navigation';
export type JoinLink = {
id: string,
remainingUses: number | null,
totalUses: number
};
export type JoinLinksModalProps = {
links: JoinLink[],
prefix: string,
onDelete: (id: string) => void,
onCreate: (remainingUses: number | null) => Promise<string>,
open: boolean,
onClose: () => void
};
export const JoinLinksModal = ({ links: initialLinks, prefix, onDelete, onCreate, open, onClose }: JoinLinksModalProps) => {
const [fullPrefix, setFullPrefix] = useState('');
const [links, setLinks] = useState(initialLinks);
const [loading, setLoading] = useState(false);
const remainingUses = useRef<HTMLInputElement | null>(null);
const router = useRouter();
useEffect(() => {
setFullPrefix(window.location.origin + process.env.NEXT_PUBLIC_BASE_PATH + prefix)
}, [prefix]);
const onModalClose = useHashNavigation({
onClose,
isOpen: open,
hash: '#links'
});
return (<Modal isOpen={open} onClose={onModalClose} size="5xl">
<ModalContent>
{onClose => <>
<ModalHeader>Invite Links</ModalHeader>
<ModalBody className="flex flex-col overflow-y-auto gap-0">
<div className="ml-auto flex gap-3 items-center text-nowrap">
Create Link
<Input label="Max uses" className="" placeholder="Unlimited" min={1} size="sm" type="number"
disabled={loading} ref={remainingUses} />
<Tooltip content="Create new link">
<Button isIconOnly disabled={loading} onPress={() => {
setLoading(true);
let uses: number | null = +remainingUses.current?.value!;
if (!Number.isInteger(uses) || uses < 1) uses = null;
onCreate(uses)
.then(id => setLinks(l => [{ id, remainingUses: uses, totalUses: 0 }, ...l]))
.finally(() => setLoading(false))
}}>
<PlusIcon className="h-1/2" />
</Button>
</Tooltip>
</div>
<Divider className="my-2" />
{!links.length && <div className="italic text-gray-500">No links</div>}
{links.map(link => (<Fragment key={link.id}>
<div className="flex items-center gap-x-0.5">
<Link href={fullPrefix + link.id} prefetch={false}
className="text-xs md:text-sm lg:text-medium underline text-secondary hover:text-primary transition text-nowrap overflow-hidden">
{fullPrefix + link.id}
</Link>
<Tooltip content="Copy link">
<Button isIconOnly variant="light" className="ml-auto"
onPress={() => navigator?.clipboard?.writeText(fullPrefix + link.id)}>
<ClipboardDocumentIcon className="h-3/4" />
</Button>
</Tooltip>
<Tooltip content={<span className="text-danger">Delete link</span>}>
<Button isIconOnly variant="light" color="danger" onPress={() => {
onDelete(link.id);
setLinks(l => l.filter(l => l.id !== link.id));
}}>
<TrashIcon className="h-3/4" />
</Button>
</Tooltip>
</div>
<div className="flex items-center justify-around pr-8 flex-wrap text-sm sm:text-medium">
<span><span className="font-semibold">Remaining Uses: </span>{link.remainingUses ?? '∞'}</span>
<span><span className="font-semibold">Total Uses: </span>{link.totalUses}</span>
</div>
<Divider className="my-2" />
</Fragment>))}
</ModalBody>
<ModalFooter>
<Button color="danger" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>}
</ModalContent>
</Modal>)
}

View File

@ -0,0 +1,10 @@
import { UserGroupIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
export const JoinSuccess = ({ href }: { href: string }) => {
return (<main className="flex flex-col w-full m-auto items-center gap-4 pb-10 text-center">
<UserGroupIcon className="w-48 h-48 mb-10" />
<header className="text-2xl font-semibold">Success! You have joined</header>
<span>Click <Link href={href} className="underline hover:text-secondary transition">here</Link> to be redirected.</span>
</main>)
}

View File

@ -1,12 +1,12 @@
'use client';
import { Button, Card, CardBody, CardHeader, Checkbox, Divider, Input } from '@nextui-org/react';
import { Button, Card, CardBody, CardHeader, Divider, Input } from '@nextui-org/react';
import { BackButton } from '@/components/back-button';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import { useState } from 'react';
import { login } from '@/actions/auth';
import { redirect, useSearchParams } from 'next/navigation';
import { redirect } from 'next/navigation';
import { useUser } from '@/helpers/use-user';
export type LoginCardProps = {

View File

@ -2,7 +2,7 @@
import { Button, Card, CardBody, Slider } from '@nextui-org/react';
import { PauseCircleIcon, PlayCircleIcon } from '@heroicons/react/24/solid';
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { ReactNode, useEffect, useState } from 'react';
import { useAudio } from '@/helpers/use-audio';
export type MusicPlayerProps = {

View File

@ -0,0 +1,73 @@
'use client';
import { Button, Checkbox, Modal, ModalContent, ModalHeader, Tooltip } from '@nextui-org/react';
import { useHashNavigation } from '@/helpers/use-hash-navigation';
import { ARCADE_PERMISSION_NAMES, ArcadePermissions, USER_PERMISSION_NAMES, UserPermissions } from '@/types/permissions';
import Link from 'next/link';
import { ModalBody, ModalFooter } from '@nextui-org/modal';
import { useEffect, useState } from 'react';
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
export type PermissionEditModalUser = {
permissions: number,
uuid?: string,
id: number,
username?: string | null
};
type PermissionEditModalProps = {
user: PermissionEditModalUser | null,
onClose: () => void,
permissions: (typeof USER_PERMISSION_NAMES) | (typeof ARCADE_PERMISSION_NAMES),
displayUpTo?: UserPermissions | ArcadePermissions,
onEdit: (id: number, permissions: number) => void
};
export const PermissionEditModal = ({ user, onClose, permissions, displayUpTo, onEdit }: PermissionEditModalProps) => {
const onModalClose = useHashNavigation({
onClose,
isOpen: user !== null,
hash: '#permissions'
});
const [editingPermissions, setEditingPermissions] = useState(0);
useEffect(() => {
if (user) setEditingPermissions(user.permissions);
}, [user?.permissions])
return (<Modal onClose={onModalClose} isOpen={user !== null}>
<ModalContent>
{onClose => <>
<ModalHeader>
Editing user&nbsp;{user?.uuid && <Link href={`/user/${user.uuid}`} className="underline hover:text-secondary transition">
{user?.username}
</Link>}
</ModalHeader>
<ModalBody>
{[...permissions].filter(([p]) => p <= (displayUpTo ?? Infinity))
.map(([permission, { description, title }]) => <div key={permission} className="flex gap-2 items-center">
<Checkbox size="lg" isSelected={!!(editingPermissions & (1 << permission))}
onValueChange={selected => setEditingPermissions(p =>
selected ? (p | (1 << permission)) : (p & ~(1 << permission)))}>
{title}
</Checkbox>
<Tooltip content={description}>
<QuestionMarkCircleIcon className="h-6" />
</Tooltip>
</div>)}
</ModalBody>
<ModalFooter>
<Button variant="light" color="danger" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={() => {
onEdit(user?.id!, editingPermissions);
onClose();
}}>
Save
</Button>
</ModalFooter>
</>}
</ModalContent>
</Modal>)
}

View File

@ -0,0 +1,17 @@
'use client';
import { useUser } from '@/helpers/use-user';
import { login } from '@/actions/auth';
import { LockClosedIcon } from '@heroicons/react/24/outline';
export const PrivateVisibilityError = () => {
const user = useUser();
return (<main className="flex flex-col w-full m-auto items-center gap-4 pb-10 text-center">
<LockClosedIcon className="w-48 mb-10" />
<header className="text-2xl font-semibold">This page is private.</header>
{!user ? <span>You may be able to access it by&nbsp;
<span className="underline hover:text-secondary transition cursor-pointer" onClick={() => login()}>logging in.</span>
</span> : <span>You do not have permission to view this page.</span>}
</main>);
}

View File

@ -0,0 +1,71 @@
import { createContext, ReactNode, useCallback, useContext, useRef, useState } from 'react';
import { Button, Input, InputProps, Modal, ModalContent, ModalHeader } from '@nextui-org/react';
import { ModalBody, ModalFooter } from '@nextui-org/modal';
import { useHashNavigation } from '@/helpers/use-hash-navigation';
type PromptOptions = { title: string, message: string } &
Partial<Pick<InputProps, 'type' | 'name' | 'label' | 'placeholder'>>;
type PromptCallback = (options: PromptOptions, onConfirm: (val: string) => void, onCancel?: () => void) => void;
const PromptContext = createContext<PromptCallback>(() => {});
export const PromptProvider = ({ children }: { children: ReactNode }) => {
const [options, setOptions] = useState<PromptOptions | null>(null);
const confirmCallback = useRef<(val: string) => void>();
const cancelCallback = useRef<() => void>();
const inputRef = useRef<HTMLInputElement | null>(null);
const setPrompt: PromptCallback = useCallback((options, onConfirm, onCancel) => {
setOptions(options);
confirmCallback.current = onConfirm;
cancelCallback.current = onCancel;
}, []);
const close = () => {
setOptions(null);
confirmCallback.current = undefined;
cancelCallback.current = undefined;
};
const onModalClose = useHashNavigation({
onClose: close,
isOpen: options !== null,
hash: '#prompt'
});
const { title, message, ...inputProps } = options ?? {};
return (<>
<Modal isOpen={options !== null} onClose={onModalClose}>
<ModalContent>
{onClose => <>
<ModalHeader className="text-danger">{ title }</ModalHeader>
<ModalBody>
{ message }
<Input type="text" size="sm" {...inputProps} ref={inputRef} />
</ModalBody>
<ModalFooter className="gap-2">
<Button onPress={() => {
if (cancelCallback.current)
setTimeout(cancelCallback.current, 5);
onClose();
}} color="danger" variant="light" >
Cancel
</Button>
<Button onPress={() => {
if (confirmCallback.current)
setTimeout(confirmCallback.current, 5, inputRef.current?.value ?? '');
onClose();
}} color="primary">
Confirm
</Button>
</ModalFooter>
</>}
</ModalContent>
</Modal>
<PromptContext.Provider value={setPrompt}>
{children}
</PromptContext.Provider>
</>);
}
export const usePromptModal = () => useContext(PromptContext);

View File

@ -1,5 +1,4 @@
import { ReactNode } from 'react';
import { CookiesProvider } from 'next-client-cookies/server';
import { auth } from '@/auth';
import { SessionProvider } from 'next-auth/react';

View File

@ -1,4 +1,4 @@
import React, { createContext, ReactNode, useContext, useState } from 'react';
import { createContext, ReactNode, useContext, useState } from 'react';
import './ticker.scss';
export type TickerProps = {

146
src/data/arcade.ts Normal file
View File

@ -0,0 +1,146 @@
import { sql } from 'kysely';
import { db } from '@/db';
import { JoinPrivacy, Visibility } from '@/types/privacy-visibility';
import { hasArcadePermission, hasPermission } from '@/helpers/permissions';
import { ArcadePermissions, UserPermissions } from '@/types/permissions';
import { UserPayload } from '@/types/user';
import { userIsVisible, withUsersVisibleTo } from '@/data/user';
import { COUNTRY_CODES } from '@/types/country';
const createArcadeExtIfNecessary = async () => {
await db.transaction().execute(async trx => {
const updateExtArcades = await trx.selectFrom('arcade')
.leftJoin('actaeon_arcade_ext as ext', 'arcade.id', 'ext.arcadeId')
.where('ext.arcadeId', 'is', null)
.select('arcade.id')
.execute();
if (!updateExtArcades.length) return;
await trx.insertInto('actaeon_arcade_ext')
.values(updateExtArcades.map(({ id }) => ({
arcadeId: id,
uuid: sql`uuid_v4()`,
visibility: Visibility.PRIVATE,
joinPrivacy: JoinPrivacy.INVITE_ONLY
})))
.executeTakeFirst();
});
}
export const getArcades = async ({ user, uuids, includeUnlisted }: { user?: UserPayload | null, uuids?: string[], includeUnlisted?: boolean }) => {
await createArcadeExtIfNecessary().catch(console.error);
const result = await withUsersVisibleTo(user)
.selectFrom('arcade')
.innerJoin('actaeon_arcade_ext as ext', 'arcade.id', 'ext.arcadeId')
.leftJoin('arcade_owner', join => join.onRef('arcade_owner.arcade', '=', 'arcade.id')
.on('arcade_owner.user', '=', user?.id!))
.leftJoin(eb => eb.selectFrom('arcade_owner as o')
.innerJoin('aime_user as u', 'u.id', 'o.user')
.innerJoin('actaeon_user_ext as owner_ext', 'u.id', 'owner_ext.userId')
.where('o.permissions', '>=', 1 << ArcadePermissions.OWNER)
.where(userIsVisible('o.user'))
.select(({ fn }) => ['u.username', fn.min('u.id').as('id'), 'owner_ext.uuid', 'o.arcade'])
.groupBy('o.arcade')
.as('owner'), join => join.onRef('owner.arcade', '=', 'arcade.id'))
.where(({ eb, and }) => and([
...(uuids?.length ? [eb('ext.uuid', 'in', uuids)] : [eb.lit(true)])
]))
.select(({ selectFrom, or, eb }) => [
or([
// acmod can view all
...(hasPermission(user?.permissions, UserPermissions.ACMOD) ? [eb.lit(true)] : []),
// public arcades are visible by default
eb('ext.visibility', '=', Visibility.PUBLIC),
...(includeUnlisted ? [eb('ext.visibility', '=', Visibility.UNLISTED)] : []),
// show arcades this user is a member of
eb('arcade_owner.user', 'is not', null),
]).as('visible'),
'ext.uuid', 'ext.visibility', 'ext.joinPrivacy',
'arcade_owner.permissions',
'arcade.id', 'arcade.name', 'arcade.nickname', 'arcade.country', 'arcade.country_id', 'arcade.state',
'arcade.city', 'arcade.region_id', 'arcade.timezone', 'arcade.ip',
'owner.id as ownerId', 'owner.username as ownerUsername', 'owner.uuid as ownerUuid',
selectFrom('arcade_owner as o2')
.whereRef('o2.arcade', '=', 'arcade.id')
.select(({ fn }) => fn.count('o2.arcade').as('arcade'))
.as('userCount'),
selectFrom('machine')
.whereRef('machine.arcade', '=', 'arcade.id')
.select(({ fn }) => fn.count('machine.id').as('id'))
.as('machineCount')
])
.execute();
return result.map(({ ownerId, ip, ...rest }) => ({
...rest,
// hide arcade ip if plain viewer
ip: hasArcadePermission(rest.permissions, user?.permissions,
[ArcadePermissions.BOOKKEEP, ArcadePermissions.EDITOR, ArcadePermissions.REGISTRAR]) ? ip : null
}));
}
type ArcadeUserOpts = { arcade: number, user?: UserPayload | null, permissions?: number | null };
export const getArcadeUsers = async ({ arcade, user, permissions }: ArcadeUserOpts) => {
const res = await withUsersVisibleTo(user, { allArcade: hasArcadePermission(permissions, user?.permissions, ArcadePermissions.BOOKKEEP) })
.selectFrom('arcade_owner as o')
.innerJoin('aime_user as u', 'u.id', 'o.user')
.innerJoin('actaeon_user_ext as uext', 'u.id', 'uext.userId')
.where('o.arcade', '=', arcade)
.select([
userIsVisible('o.user').as('visible'),
'u.username', 'o.permissions', 'u.id',
'uext.uuid'
])
.orderBy('o.permissions desc')
.execute();
return res.map(({ username, visible, uuid, ...rest }) => visible ? ({ ...rest, username, uuid }) : ({ ...rest }));
};
export const getArcadeCabs = async ({ arcade, user, permissions }: ArcadeUserOpts) => {
return db.selectFrom('machine')
.where('arcade', '=', arcade)
.select([
'id', 'game', 'country', 'timezone', 'ota_enable', 'is_cab', 'memo', 'arcade',
...(hasArcadePermission(permissions, user?.permissions, ArcadePermissions.BOOKKEEP) ? [
'board', 'serial'
] as const : [])
])
.execute();
}
export const getArcadeInviteLinks = async ({ arcade, user, permissions }: ArcadeUserOpts) => {
if (!hasPermission(permissions, ArcadePermissions.OWNER) &&
!hasPermission(user?.permissions, UserPermissions.OWNER)) return [];
return db.selectFrom('actaeon_arcade_join_keys')
.where('arcadeId', '=', arcade)
.selectAll()
.execute();
}
export const getArcadePermissions = async (user: UserPayload, arcade: number) => (await db.selectFrom('arcade_owner as o')
.where('o.arcade', '=', arcade)
.where('o.user', '=', user.id!)
.select('o.permissions')
.executeTakeFirst())?.permissions;
export const countryValidator = (val: string | null | undefined) => {
if (!COUNTRY_CODES.has(val!))
throw new Error('Invalid country');
};
export type ArcadeCab = Awaited<ReturnType<typeof getArcadeCabs>>[number];
export type ArcadeUser = Awaited<ReturnType<typeof getArcadeUsers>>[number];
export type Arcade = Awaited<ReturnType<typeof getArcades>>[number];
export type ArcadeLink = Awaited<ReturnType<typeof getArcadeInviteLinks>>[number];

71
src/data/user.ts Normal file
View File

@ -0,0 +1,71 @@
import { UserPayload, UserVisibility } from '@/types/user';
import { hasPermission } from '@/helpers/permissions';
import { UserPermissions } from '@/types/permissions';
import { sql } from 'kysely';
import { db } from '@/db';
type WithUsersVisibleToOptions = {
// ignore targets's visibility settings and always show if they share a team with the user
allTeam?: boolean,
// ignore targets's visibility settings and always show if they share an arcade with the user
allArcade?: boolean,
// ignore targets's visibility settings and always show if they are a friend with the user
allFriends?: boolean
}
export const withUsersVisibleTo = (viewingUser: UserPayload | null | undefined, opts?: WithUsersVisibleToOptions) => {
// usermod can always view other users
if (hasPermission(viewingUser?.permissions, UserPermissions.USERMOD))
return db.with('visible', db => db.selectFrom('aime_user as u')
.select('u.id'));
return db.with('visible', db => db.selectFrom('aime_user as u')
.innerJoin('actaeon_user_ext as ext', 'u.id', 'ext.userId')
.where(({ eb, and, or, exists, selectFrom }) => or([
// public visibility
eb('ext.visibility', '>=', UserVisibility.EVERYONE),
// requesting user
eb('u.id', '=', viewingUser?.id!),
...(viewingUser ? [
// visible to logged in users
sql<boolean>`(ext.visibility & ${sql.lit(UserVisibility.LOGGED_IN)})`,
// visible to other arcade members
and([
...(opts?.allArcade ? [] : [sql<boolean>`(ext.visibility & ${sql.lit(UserVisibility.ARCADE)})`]),
exists(selectFrom('arcade_owner as a1')
.innerJoin('arcade_owner as a2', 'a1.arcade', 'a2.arcade')
.where('a1.user', '=', viewingUser.id!)
.whereRef('a2.user', '=', 'u.id')
.select('a1.user'))
]),
// visible to friends
and([
...(opts?.allFriends ? [] : [sql<boolean>`(ext.visibility & ${sql.lit(UserVisibility.FRIENDS)})`]),
exists(selectFrom('actaeon_user_friends as f')
.where('f.user1', '=', viewingUser.id!)
.whereRef('f.user2', '=', 'u.id')
.select('f.user1'))
]),
// visible to teammates
and([
...(opts?.allTeam ? [] : [sql<boolean>`(ext.visibility & ${sql.lit(UserVisibility.TEAMMATES)})`]),
exists(selectFrom('actaeon_user_ext as ue1')
.innerJoin('actaeon_user_ext as ue2', 'ue1.team', 'ue2.team')
.where('ue1.userId', '=', viewingUser.id!)
.whereRef('ue2.userId', '=', 'u.id')
.select('ue1.userId'))
])
] : [])
]))
.select('u.id')
);
}
export const userIsVisible = (userKey: string) => {
return sql<boolean>`(EXISTS (SELECT id FROM visible WHERE id = ${sql.raw(userKey)}))`;
}

View File

@ -1,6 +1,5 @@
import { CHUNI_DIFFICULTIES } from '@/helpers/chuni/difficulties';
import { SelectItem } from '@nextui-org/react';
import React from 'react';
import { FilterField } from '@/components/filter-sorter';
import { ChuniMusic } from '@/actions/chuni/music';
import { CHUNI_GENRES } from '@/helpers/chuni/genres';

View File

@ -1,5 +1,3 @@
import { sql } from 'kysely';
export const CHUNI_MUSIC_PROPERTIES = ['music.songId',
'music.chartId',
'music.title',

17
src/helpers/keychip.ts Normal file
View File

@ -0,0 +1,17 @@
import { choice, randomInt } from '@/helpers/random';
export const KEYCHIP_PLATFORMS = {
RING: ['A72E'],
NU: ['A60E'],
NUSX: ['A61X', 'A69X'],
ALLS: ['A63E']
} as const;
export const generateRandomKeychip = () => {
const platform = choice(Object.values(KEYCHIP_PLATFORMS).flat());
return platform +
// TODO: looks like more keychip numbers than just these are accepted by games
(platform[3] === 'X' ? '20A' : `01${choice('ABCDU')}`) +
randomInt(0, 9999_9999).toString().padStart(8, '0');
};

View File

@ -2,21 +2,65 @@ import { ArcadePermissions, UserPermissions } from '@/types/permissions';
import { redirect } from 'next/navigation';
export const hasPermission = <T extends UserPermissions | ArcadePermissions>(userPermission: number | null | undefined, ...requestedPermission: T[]) => {
/**
* Check if user has permission
* @param userPermission user's permission mask
* @param requestedPermission requested permission, passing a single permission will check if a user has that permission,
* passing an array will check that the user has at least one of the permissions
*/
export const hasPermission = <T extends UserPermissions | ArcadePermissions>(userPermission: number | null | undefined, ...requestedPermission: (T | T[])[]) => {
if (!userPermission)
return false;
if (userPermission & (1 << UserPermissions.OWNER))
if (userPermission & (1 << UserPermissions.OWNER) || !requestedPermission.length)
return true;
const permissionMask = requestedPermission
.reduce((mask, permission) => mask | (1 << permission), 0);
return requestedPermission.every(perm => {
if (Array.isArray(perm))
return perm.some(p => userPermission & (1 << p));
return (userPermission & permissionMask) === permissionMask;
return userPermission & (1 << perm);
});
}
export const requirePermission = <T extends UserPermissions | ArcadePermissions>(userPermission: number | null | undefined, ...requestedPermission: T[]) => {
/**
* Check if user has permission and redirect to unauthorized if not
* @param userPermission user's permission mask
* @param requestedPermission requested permission, passing a single permission will check if a user has that permission,
* passing an array will check that the user has at least one of the permissions
*/
export const requirePermission = <T extends UserPermissions | ArcadePermissions>(userPermission: number | null | undefined, ...requestedPermission: (T | T[])[]) => {
if (!hasPermission(userPermission, ...requestedPermission))
redirect('/unauthorized');
}
/**
* Check if user has arcade permission
*
* @param userArcadePermission user's arcade permission mask
* @param userPermission user's permission mask
* @param requestedPermissions requested permissions
*/
export const hasArcadePermission = (userArcadePermission: number | null | undefined, userPermission: number | null | undefined,
...requestedPermissions: (ArcadePermissions | ArcadePermissions[])[]) => {
if (hasPermission(userPermission, UserPermissions.ACMOD))
return true;
if (!userArcadePermission)
return false;
return hasPermission(userArcadePermission, ...requestedPermissions);
}
/**
* Check if user has arcade permission
*
* @param userArcadePermission user's arcade permission mask
* @param userPermission user's permission mask
* @param requestedPermissions requested permissions
*/
export const requireArcadePermission = (userArcadePermission: number | null | undefined, userPermission: number | null | undefined,
...requestedPermissions: (ArcadePermissions | ArcadePermissions[])[]) => {
if (!hasArcadePermission(userArcadePermission, userPermission, ...requestedPermissions))
redirect('/unauthorized');
}

19
src/helpers/random.ts Normal file
View File

@ -0,0 +1,19 @@
export const randomInt = (min: number, max: number) => {
const a = new Uint32Array(1);
crypto.getRandomValues(a);
return a[0] % (max - min + 1) + min;
};
export const choice = <T>(arr: ArrayLike<T>): T => {
return arr[randomInt(0, arr.length - 1)];
}
export const randomString = (length: number) => {
return [...Array(length)].map(() => {
let l = '';
do {
l = String.fromCharCode(randomInt(48, 123));
} while (!/[A-Z\d]/i.test(l));
return l;
}).join('');
};

View File

@ -0,0 +1,37 @@
import { useWindowListener } from '@/helpers/use-window-listener';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
type UseHashNavigationOptions = {
onClose: () => void,
isOpen: boolean,
hash: string
};
export const useHashNavigation = (options: UseHashNavigationOptions) => {
const router = useRouter();
useWindowListener('hashchange', () => {
if (window.location.hash !== options.hash && options.isOpen)
options.onClose();
else if (window.location.hash === options.hash && !options.isOpen)
router.replace('', { scroll: false });
}, [options.isOpen, options.hash])
useEffect(() => {
if (window.location.hash === options.hash) {
options.onClose();
router.replace('', { scroll: false });
}
}, [options.hash]);
useEffect(() => {
if (options.isOpen)
router.push(options.hash, { scroll: false });
}, [options.isOpen, options.hash]);
return () => {
router.back();
options.onClose();
};
};

View File

@ -0,0 +1,3 @@
export const IP_REGEX = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
export const TIMEZONE_REGEX = /^[+-]\d{2}:?\d{2}$/;
export const EMAIL_REGEX = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/i;

View File

@ -23,6 +23,9 @@ export const MAIN_ROUTES: Route = {
routes: [{
url: '/dashboard',
name: 'Overview'
}, {
url: '/arcade',
name: 'Arcades'
}]
};

1
src/types/awaitable.ts Normal file
View File

@ -0,0 +1 @@
export type Awaitable<T> = PromiseLike<T> | T;

9
src/types/country.ts Normal file
View File

@ -0,0 +1,9 @@
export const COUNTRY_CODES = new Map([
['JPN', 'Japan'],
['USA', 'United States'],
['HKG', 'Hong Kong'],
['SGP', 'Singapore'],
['KOR', 'South Korea'],
['TWN', 'Taiwan'],
['CHN', 'China']
]);

78
src/types/db.d.ts vendored
View File

@ -1,5 +1,19 @@
// Do not edit. File generated by kysely-codegen
export interface ActaeonArcadeExt {
arcadeId: number;
joinPrivacy: number;
uuid: string;
visibility: number;
}
export interface ActaeonArcadeJoinKeys {
arcadeId: number;
id: string;
remainingUses: number | null;
totalUses: number;
}
export interface ActaeonChuniStaticMapIcon {
id: number;
imagePath: string | null;
@ -29,6 +43,35 @@ export interface ActaeonChuniStaticTrophies {
rareType: number | null;
}
export interface ActaeonTeamJoinKeys {
id: string;
remainingUses: number | null;
teamId: string;
totalUses: number;
}
export interface ActaeonTeams {
chuniTeam: number;
joinPrivacy: number;
name: string | null;
owner: number;
uuid: string;
visibility: number;
}
export interface ActaeonUserExt {
homepage: string | null;
team: string | null;
userId: number;
uuid: string;
visibility: number;
}
export interface ActaeonUserFriends {
user1: number;
user2: number;
}
export interface AimeCard {
access_code: string | null;
created_date: Date | null;
@ -482,6 +525,18 @@ export interface ChuniProfileOverpower {
user: number;
}
export interface ChuniProfileRating {
difficultId: number | null;
id: number;
index: number;
musicId: number | null;
romVersionCode: number | null;
score: number | null;
type: string;
user: number;
version: number;
}
export interface ChuniProfileRecentRating {
id: number;
recentRating: string | null;
@ -1502,7 +1557,6 @@ export interface Mai2Playlog {
characterLevel5: number | null;
comboStatus: number | null;
deluxscore: number | null;
extBool1: number | null;
extNum1: number | null;
extNum2: number | null;
extNum4: number | null;
@ -1624,7 +1678,6 @@ export interface Mai2ProfileDetail {
compatibleCmVersion: string | null;
contentBit: number | null;
courseRank: number | null;
currentPlayCount: number | null;
dailyBonusDate: string | null;
dailyCourseBonusDate: string | null;
dateTime: number | null;
@ -1671,7 +1724,6 @@ export interface Mai2ProfileDetail {
playerRating: number | null;
playSyncCount: number | null;
playVsCount: number | null;
renameCredit: number | null;
selectMapId: number | null;
titleId: number | null;
totalAchievement: number | null;
@ -2267,6 +2319,18 @@ export interface OngekiProfileOption {
volTap: number | null;
}
export interface OngekiProfileRating {
difficultId: number | null;
id: number;
index: number;
musicId: number | null;
romVersionCode: number | null;
score: number | null;
type: string;
user: number;
version: number;
}
export interface OngekiProfileRatingLog {
dataVersion: string | null;
highestRating: number | null;
@ -3262,10 +3326,16 @@ export interface WaccaTrophy {
}
export interface DB {
actaeon_arcade_ext: ActaeonArcadeExt;
actaeon_arcade_join_keys: ActaeonArcadeJoinKeys;
actaeon_chuni_static_map_icon: ActaeonChuniStaticMapIcon;
actaeon_chuni_static_name_plate: ActaeonChuniStaticNamePlate;
actaeon_chuni_static_system_voice: ActaeonChuniStaticSystemVoice;
actaeon_chuni_static_trophies: ActaeonChuniStaticTrophies;
actaeon_team_join_keys: ActaeonTeamJoinKeys;
actaeon_teams: ActaeonTeams;
actaeon_user_ext: ActaeonUserExt;
actaeon_user_friends: ActaeonUserFriends;
aime_card: AimeCard;
aime_user: AimeUser;
arcade: Arcade;
@ -3289,6 +3359,7 @@ export interface DB {
chuni_profile_option: ChuniProfileOption;
chuni_profile_option_ex: ChuniProfileOptionEx;
chuni_profile_overpower: ChuniProfileOverpower;
chuni_profile_rating: ChuniProfileRating;
chuni_profile_recent_rating: ChuniProfileRecentRating;
chuni_profile_region: ChuniProfileRegion;
chuni_profile_team: ChuniProfileTeam;
@ -3381,6 +3452,7 @@ export interface DB {
ongeki_profile_data: OngekiProfileData;
ongeki_profile_kop: OngekiProfileKop;
ongeki_profile_option: OngekiProfileOption;
ongeki_profile_rating: OngekiProfileRating;
ongeki_profile_rating_log: OngekiProfileRatingLog;
ongeki_profile_recent_rating: OngekiProfileRecentRating;
ongeki_profile_region: OngekiProfileRegion;

31
src/types/game-ids.ts Normal file
View File

@ -0,0 +1,31 @@
export const GAME_IDS = new Map([
['SDHD', 'CHUNITHM NEW'],
['SDGS', 'CHUNITHM International'],
['SDBT', 'CHUNITHM'],
['SDED', 'Card Maker'],
['SDCA', 'crossbeats REV.'],
['SBZV', 'Project DIVA'],
['SDGT', 'Initial D THE ARCADE'],
['SDDF', 'Initial D Arcade Stage Zero'],
['SDEZ', 'maimai DX'],
['SDEY', 'maimai FiNALE'],
['SDDZ', 'maimai MiLK'],
['SDDK', 'maimai MURASAKi'],
['SDCQ', 'maimai PiNK'],
['SDBM', 'maimai ORANGE'],
['SBZF', 'maimai GreeN'],
['SBXL', 'maimai'],
['SDDT', 'ONGEKI'],
['SDAK', 'Pokken Tournament'],
['SDEW', 'Sword Art Online Arcade'],
['SDFE', 'WACCA']
]);

View File

@ -7,6 +7,13 @@ export const enum UserPermissions {
OWNER = 7 // can do anything
}
export const USER_PERMISSION_NAMES = new Map([
[UserPermissions.USERMOD, { title: 'User Moderator', description: 'Can moderate, view, and edit all users' }],
[UserPermissions.ACMOD, { title: 'Arcade Moderator', description: 'Can create, delete, and modify arcades' }],
[UserPermissions.SYSADMIN, { title: 'Sysadmin', description: 'Can change server settings' }],
[UserPermissions.OWNER, { title: 'Owner', description: 'Can do anything' }]
]);
export const enum ArcadePermissions {
VIEW = 0, // view info and cabs
BOOKKEEP = 1, // view bookkeeping info
@ -15,3 +22,10 @@ export const enum ArcadePermissions {
OWNER = 7 // can do anything
}
export const ARCADE_PERMISSION_NAMES = new Map([
[ArcadePermissions.BOOKKEEP, { title: 'Bookkeeper', description: 'Can view bookkeeping info (registered users and arcade IP)' }],
[ArcadePermissions.EDITOR, { title: 'Editor', description: 'Can edit arcade settings' }],
[ArcadePermissions.REGISTRAR, { title: 'Registrar', description: 'Can add and edit cabs' }],
[ArcadePermissions.OWNER, { title: 'Arcade Owner', description: 'Can do anything' }]
]);

View File

@ -0,0 +1,14 @@
export const enum Visibility {
PRIVATE = 0,
UNLISTED = 1,
PUBLIC = 2
}
export const VISIBILITY_VALUES = new Set<Visibility>([0,1,2]);
export const enum JoinPrivacy {
INVITE_ONLY = 0,
PUBLIC = 1
}
export const PRIVACY_VALUES = new Set<JoinPrivacy>([0,1]);

107
src/types/region.ts Normal file
View File

@ -0,0 +1,107 @@
// https://gitea.tendokyu.moe/Hay1tsme/artemis/src/commit/87c7c91e3a7158aabfd2b8dbf69d6a5ca0249da1/core/const.py#L49
export const ALLNET_JAPAN_REGION = new Map([
[0, "None"],
[1, "Aichi"],
[2, "Aomori"],
[3, "Akita"],
[4, "Ishikawa"],
[5, "Ibaraki"],
[6, "Iwate"],
[7, "Ehime"],
[8, "Oita"],
[9, "Osaka"],
[10, "Okayama"],
[11, "Okinawa"],
[12, "Kagawa"],
[13, "Kagoshima"],
[14, "Kanagawa"],
[15, "Gifu"],
[16, "Kyoto"],
[17, "Kumamoto"],
[18, "Gunma"],
[19, "Kochi"],
[20, "Saitama"],
[21, "Saga"],
[22, "Shiga"],
[23, "Shizuoka"],
[24, "Shimane"],
[25, "Chiba"],
[26, "Tokyo"],
[27, "Tokushima"],
[28, "Tochigi"],
[29, "Tottori"],
[30, "Toyama"],
[31, "Nagasaki"],
[32, "Nagano"],
[33, "Nara"],
[34, "Niigata"],
[35, "Hyogo"],
[36, "Hiroshima"],
[37, "Fukui"],
[38, "Fukuoka"],
[39, "Fukushima"],
[40, "Hokkaido"],
[41, "Mie"],
[42, "Miyagi"],
[43, "Miyazaki"],
[44, "Yamagata"],
[45, "Yamaguchi"],
[46, "Yamanashi"],
[47, "Wakayama"]
]);
export const WACCA_REGION = new Map([
[1, "Hokkaido"],
[2, "Aomori"],
[3, "Iwate"],
[4, "Miyagi"],
[5, "Akita"],
[6, "Yamagata"],
[7, "Fukushima"],
[8, "Ibaraki"],
[9, "Tochigi"],
[10, "Gunma"],
[11, "Saitama"],
[12, "Chiba"],
[13, "Tokyo"],
[14, "Kanagawa"],
[15, "Niigata"],
[16, "Toyama"],
[17, "Ishikawa"],
[18, "Fukui"],
[19, "Yamanashi"],
[20, "Nagano"],
[21, "Gifu"],
[22, "Shizuoka"],
[23, "Aichi"],
[24, "Mie"],
[25, "Shiga"],
[26, "Kyoto"],
[27, "Osaka"],
[28, "Hyogo"],
[29, "Nara"],
[30, "Wakayama"],
[31, "Tottori"],
[32, "Shimane"],
[33, "Okayama"],
[34, "Hiroshima"],
[35, "Yamaguchi"],
[36, "Tokushima"],
[37, "Kagawa"],
[38, "Ehime"],
[39, "Kochi"],
[40, "Fukuoka"],
[41, "Saga"],
[42, "Nagasaki"],
[43, "Kumamoto"],
[44, "Oita"],
[45, "Miyazaki"],
[46, "Kagoshima"],
[47, "Okinawa"],
[48, "United States"],
[49, "Taiwan"],
[50, "Hong Kong"],
[51, "Singapore"],
[52, "Korea"]
]);

View File

@ -1,6 +1,6 @@
import { AimeUser } from '@/types/db';
import { AimeUser, DB } from '@/types/db';
export type DBUserPayload = Omit<AimeUser, 'password'> & {
export type DBUserPayload = Omit<AimeUser, 'password'> & Omit<DB['actaeon_user_ext'], 'userId'> & {
chuni: boolean
};
@ -11,3 +11,11 @@ export type UserPayload = {
iat: number,
exp: number,
};
export const enum UserVisibility {
FRIENDS = 1,
TEAMMATES = 2,
ARCADE = 4,
LOGGED_IN = 8,
EVERYONE = 16
}

View File

@ -0,0 +1,11 @@
import { Awaitable } from '@/types/awaitable';
type Validator<T, K extends keyof T, D> = undefined extends D ?
(val: T[K]) => Awaitable<T[K] | null | undefined> | void :
(val: T[K], data: D) => Awaitable<T[K] | null | undefined> | void;
export type ValidatorMap<T, D=undefined> = {
set: <K extends keyof T>(key: K, val: Validator<T, K, D>) => void,
get: <K extends keyof T>(key: K) => Validator<T, K, D> | undefined,
has: (key: string) => boolean
};