add arcade and cab editing
This commit is contained in:
parent
b66502e9c8
commit
6a3358e03e
53
migrations/20240321005239-create-uuidv4.js
Normal file
53
migrations/20240321005239-create-uuidv4.js
Normal 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
|
||||
};
|
53
migrations/20240321005359-create-arcade-ext.js
Normal file
53
migrations/20240321005359-create-arcade-ext.js
Normal 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
|
||||
};
|
53
migrations/20240321014407-create-user-ext.js
Normal file
53
migrations/20240321014407-create-user-ext.js
Normal 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
|
||||
};
|
53
migrations/20240321022354-create-user-friends.js
Normal file
53
migrations/20240321022354-create-user-friends.js
Normal 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
|
||||
};
|
53
migrations/20240321023511-create-teams.js
Normal file
53
migrations/20240321023511-create-teams.js
Normal 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
|
||||
};
|
53
migrations/20240321023539-add-team-to-user-ext.js
Normal file
53
migrations/20240321023539-add-team-to-user-ext.js
Normal 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
|
||||
};
|
53
migrations/20240322035901-create-arcade-join-keys.js
Normal file
53
migrations/20240322035901-create-arcade-join-keys.js
Normal 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
|
||||
};
|
53
migrations/20240322035910-create-team-join-keys.js
Normal file
53
migrations/20240322035910-create-team-join-keys.js
Normal 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
|
||||
};
|
1
migrations/sqls/20240321005239-create-uuidv4-down.sql
Normal file
1
migrations/sqls/20240321005239-create-uuidv4-down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP FUNCTION uuid_v4;
|
24
migrations/sqls/20240321005239-create-uuidv4-up.sql
Normal file
24
migrations/sqls/20240321005239-create-uuidv4-up.sql
Normal 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
|
@ -0,0 +1 @@
|
||||
DROP TABLE actaeon_arcade_ext;
|
11
migrations/sqls/20240321005359-create-arcade-ext-up.sql
Normal file
11
migrations/sqls/20240321005359-create-arcade-ext-up.sql
Normal 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
|
||||
);
|
1
migrations/sqls/20240321014407-create-user-ext-down.sql
Normal file
1
migrations/sqls/20240321014407-create-user-ext-down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE actaeon_user_ext;
|
12
migrations/sqls/20240321014407-create-user-ext-up.sql
Normal file
12
migrations/sqls/20240321014407-create-user-ext-up.sql
Normal 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
|
||||
);
|
@ -0,0 +1 @@
|
||||
DROP TABLE actaeon_user_friends;
|
10
migrations/sqls/20240321022354-create-user-friends-up.sql
Normal file
10
migrations/sqls/20240321022354-create-user-friends-up.sql
Normal 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
|
||||
);
|
1
migrations/sqls/20240321023511-create-teams-down.sql
Normal file
1
migrations/sqls/20240321023511-create-teams-down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE actaeon_teams;
|
15
migrations/sqls/20240321023511-create-teams-up.sql
Normal file
15
migrations/sqls/20240321023511-create-teams-up.sql
Normal 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
|
||||
);
|
@ -0,0 +1,3 @@
|
||||
ALTER TABLE actaeon_user_ext
|
||||
DROP CONSTRAINT fk_team,
|
||||
DROP COLUMN team;
|
@ -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;
|
||||
|
@ -0,0 +1 @@
|
||||
DROP TABLE actaeon_arcade_join_keys;
|
@ -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
|
||||
);
|
@ -0,0 +1 @@
|
||||
DROP TABLE actaeon_team_join_keys;
|
11
migrations/sqls/20240322035910-create-team-join-keys-up.sql
Normal file
11
migrations/sqls/20240322035910-create-team-join-keys-up.sql
Normal 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
21
package-lock.json
generated
@ -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",
|
||||
|
@ -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
211
src/actions/arcade.ts
Normal 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();
|
||||
};
|
@ -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
151
src/actions/machine.ts
Normal 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 }) };
|
||||
}
|
58
src/app/(with-header)/arcade/[arcadeId]/join/[join]/page.tsx
Normal file
58
src/app/(with-header)/arcade/[arcadeId]/join/[join]/page.tsx
Normal 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}`} />);
|
||||
}
|
24
src/app/(with-header)/arcade/[arcadeId]/page.tsx
Normal file
24
src/app/(with-header)/arcade/[arcadeId]/page.tsx
Normal 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} />)
|
||||
};
|
72
src/app/(with-header)/arcade/page.tsx
Normal file
72
src/app/(with-header)/arcade/page.tsx
Normal 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: </span>
|
||||
{getLocation(arcade)}
|
||||
</span>}
|
||||
{arcade.ip && <span className="ml-3">
|
||||
<span className="font-semibold">IP: </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 {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>);
|
||||
}
|
@ -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';
|
||||
|
@ -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';
|
||||
|
||||
|
||||
|
@ -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
3
src/app/not-found.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function NotFound() {
|
||||
return (<div className="flex w-full h-full items-center justify-center">Not Found.</div>)
|
||||
}
|
30
src/auth.ts
30
src/auth.ts
@ -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
351
src/components/arcade.tsx
Normal 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
221
src/components/cab.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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}>
|
||||
|
@ -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 = {
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<NextUIProvider className="h-full flex">
|
||||
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
</NextUIProvider>
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</ErrorProvider>);
|
||||
}
|
||||
|
61
src/components/confirm-modal.tsx
Normal file
61
src/components/confirm-modal.tsx
Normal 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);
|
31
src/components/create-arcade-button.tsx
Normal file
31
src/components/create-arcade-button.tsx
Normal 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>);
|
||||
}
|
@ -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">
|
||||
|
@ -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>,
|
||||
|
@ -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';
|
||||
|
9
src/components/invalid-link.tsx
Normal file
9
src/components/invalid-link.tsx
Normal 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>);
|
||||
};
|
102
src/components/join-links-modal.tsx
Normal file
102
src/components/join-links-modal.tsx
Normal 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>)
|
||||
}
|
10
src/components/join-success.tsx
Normal file
10
src/components/join-success.tsx
Normal 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>)
|
||||
}
|
@ -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 = {
|
||||
|
@ -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 = {
|
||||
|
73
src/components/permission-edit-modal.tsx
Normal file
73
src/components/permission-edit-modal.tsx
Normal 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 {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>)
|
||||
}
|
17
src/components/private-visibility-error.tsx
Normal file
17
src/components/private-visibility-error.tsx
Normal 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
|
||||
<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>);
|
||||
}
|
71
src/components/prompt-modal.tsx
Normal file
71
src/components/prompt-modal.tsx
Normal 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);
|
@ -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';
|
||||
|
||||
|
@ -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
146
src/data/arcade.ts
Normal 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
71
src/data/user.ts
Normal 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)}))`;
|
||||
}
|
@ -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';
|
||||
|
@ -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
17
src/helpers/keychip.ts
Normal 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');
|
||||
};
|
@ -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
19
src/helpers/random.ts
Normal 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('');
|
||||
};
|
37
src/helpers/use-hash-navigation.ts
Normal file
37
src/helpers/use-hash-navigation.ts
Normal 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();
|
||||
};
|
||||
};
|
3
src/helpers/validators.ts
Normal file
3
src/helpers/validators.ts
Normal 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;
|
@ -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
1
src/types/awaitable.ts
Normal file
@ -0,0 +1 @@
|
||||
export type Awaitable<T> = PromiseLike<T> | T;
|
9
src/types/country.ts
Normal file
9
src/types/country.ts
Normal 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
78
src/types/db.d.ts
vendored
@ -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
31
src/types/game-ids.ts
Normal 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']
|
||||
]);
|
@ -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' }]
|
||||
]);
|
||||
|
14
src/types/privacy-visibility.ts
Normal file
14
src/types/privacy-visibility.ts
Normal 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
107
src/types/region.ts
Normal 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"]
|
||||
]);
|
@ -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
|
||||
}
|
||||
|
11
src/types/validator-map.ts
Normal file
11
src/types/validator-map.ts
Normal 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
|
||||
};
|
Loading…
Reference in New Issue
Block a user