allow creation of parties
This commit is contained in:
parent
1183f0f9b6
commit
882f16356c
9 changed files with 282 additions and 17 deletions
|
@ -16,6 +16,7 @@ import UsernameData from "./interfaces/UsernameData";
|
|||
import { randomBytes } from "crypto";
|
||||
import SessionUser from "./objects/SessionUser";
|
||||
import PasswordUtility from "./utilities/PasswordUtility";
|
||||
import CreateEditPartyData from "./interfaces/CreateEditPartyData";
|
||||
|
||||
Console.customHeader(`MultiProbe server started at ${new Date()}`);
|
||||
|
||||
|
@ -56,7 +57,9 @@ fastify.register(FastifyCookie, {
|
|||
}
|
||||
});
|
||||
|
||||
// Get Methods
|
||||
fastify.setNotFoundHandler(async (req, res) => {
|
||||
return res.status(404).view("templates/404.ejs", { });
|
||||
});
|
||||
|
||||
function validateSession(cookies:{ [cookieName: string]: string | undefined }) {
|
||||
if ("MP_SESSION" in cookies && typeof(cookies["MP_SESSION"]) === "string") {
|
||||
|
@ -69,13 +72,15 @@ function validateSession(cookies:{ [cookieName: string]: string | undefined }) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
// Get Methods
|
||||
|
||||
fastify.get("/", async (req, res) => {
|
||||
let session:SessionUser | undefined;
|
||||
if (session = validateSession(req.cookies)) {
|
||||
const user = await UserService.GetUser(session.userId);
|
||||
//const groups = await UserService.GetUserParties(session.userId);
|
||||
const parties = await UserService.GetUserParties(session.userId);
|
||||
if (user) {
|
||||
return res.view("templates/home.ejs", { user, parties: [] });
|
||||
return res.view("templates/home.ejs", { user, parties });
|
||||
}
|
||||
|
||||
return res.view("templates/index.ejs", { });
|
||||
|
@ -96,8 +101,22 @@ fastify.get("/account/register", async (req, res) => {
|
|||
return res.view("templates/account/register.ejs", { });
|
||||
});
|
||||
|
||||
fastify.setNotFoundHandler(async (req, res) => {
|
||||
return res.status(404).view("templates/404.ejs", { });
|
||||
fastify.get("/party/create", async (req, res) => {
|
||||
let session:SessionUser | undefined;
|
||||
if (!(session = validateSession(req.cookies))) {
|
||||
return res.redirect(302, "/");
|
||||
}
|
||||
|
||||
return res.view("templates/party/createedit.ejs", { });
|
||||
});
|
||||
|
||||
fastify.get("/party/join", async (req, res) => {
|
||||
let session:SessionUser | undefined;
|
||||
if (!(session = validateSession(req.cookies))) {
|
||||
return res.redirect(302, "/");
|
||||
}
|
||||
|
||||
return res.view("templates/party/join.ejs", { });
|
||||
});
|
||||
|
||||
// Post Methods
|
||||
|
@ -159,6 +178,31 @@ fastify.post("/account/login", async (req, res) => {
|
|||
return res.view("templates/account/login.ejs", { });
|
||||
});
|
||||
|
||||
fastify.post("/party/create", async (req, res) => {
|
||||
try {
|
||||
let session:SessionUser | undefined;
|
||||
if (!(session = validateSession(req.cookies))) {
|
||||
return res.redirect(302, "/");
|
||||
}
|
||||
|
||||
const data = req.body as CreateEditPartyData;
|
||||
if (typeof(data.partyName) !== "string" || typeof(data.partyRef) !== "string" || data.partyName.length === 0 || data.partyRef.length === 0) {
|
||||
return res.view("templates/party/createedit.ejs", { partyName: data.partyName ?? "", partyRef: data.partyRef ?? "" });
|
||||
}
|
||||
|
||||
const party = await UserService.GetPartyByPartyRef(data.partyRef)
|
||||
if (party != null) {
|
||||
return res.view("templates/party/createedit.ejs", { partyName: data.partyName ?? "", partyRef: data.partyRef ?? "", error: "A group with that Party ID already exists" });
|
||||
}
|
||||
|
||||
await UserService.CreateParty(session.userId, data.partyName, data.partyRef);
|
||||
|
||||
return res.redirect(302, "/");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Websocket stuff
|
||||
|
||||
const websocketServer = new WebSocketServer({
|
||||
|
@ -295,4 +339,4 @@ function shutdown() {
|
|||
|
||||
process.on("SIGQUIT", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGUSR2", shutdown);
|
||||
//process.on("SIGUSR2", shutdown);
|
4
server/interfaces/CreateEditPartyData.ts
Normal file
4
server/interfaces/CreateEditPartyData.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default interface CreateEditPartyData {
|
||||
partyName?:string;
|
||||
partyRef?:string;
|
||||
}
|
34
server/objects/UserParty.ts
Normal file
34
server/objects/UserParty.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
export default class UserParty {
|
||||
public Id:number;
|
||||
public UserId:number;
|
||||
public PartyId:number;
|
||||
public CreatedByUserId:number;
|
||||
public CreatedDatetime:Date;
|
||||
public LastModifiedByUserId?:number;
|
||||
public LastModifiedDatetime?:Date;
|
||||
public DeletedByUserId?:number;
|
||||
public DeletedDatetime?:Date;
|
||||
public IsDeleted:boolean;
|
||||
|
||||
public constructor(id?:number, userId?:number, partyId?:number, createdByUserId?:number, createdDateTime?:Date, lastModifiedByUserId?:number, lastModifiedDatetime?:Date, deletedByUserId?:number, deletedDatetime?:Date, isDeleted?:boolean) {
|
||||
if (typeof(id) == "number" && typeof(userId) == "number" && typeof(partyId) == "number" && typeof(createdByUserId) == "number" && createdDateTime instanceof Date && typeof(lastModifiedByUserId) == "number" && lastModifiedDatetime instanceof Date && typeof(deletedByUserId) == "number" && deletedDatetime instanceof Date && typeof(isDeleted) == "boolean") {
|
||||
this.Id = id;
|
||||
this.UserId = userId;
|
||||
this.PartyId = partyId;
|
||||
this.CreatedByUserId = createdByUserId;
|
||||
this.CreatedDatetime = createdDateTime;
|
||||
this.LastModifiedByUserId = lastModifiedByUserId;
|
||||
this.LastModifiedDatetime = lastModifiedDatetime;
|
||||
this.DeletedByUserId = deletedByUserId;
|
||||
this.DeletedDatetime = deletedDatetime;
|
||||
this.IsDeleted = isDeleted;
|
||||
} else {
|
||||
this.Id = Number.MIN_VALUE;
|
||||
this.UserId = Number.MIN_VALUE;
|
||||
this.PartyId = Number.MIN_VALUE;
|
||||
this.CreatedByUserId = Number.MIN_VALUE;
|
||||
this.CreatedDatetime = new Date(0);
|
||||
this.IsDeleted = false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ import RepoBase from "./RepoBase";
|
|||
|
||||
export default class PartyRepo {
|
||||
public static async selectById(id:number) {
|
||||
const dbParty = await Database.Instance.query("SELECT * FROM Party WHERE Id = ? LIMIT 1", [id]);
|
||||
const dbParty = await Database.Instance.query("SELECT * FROM Party WHERE Id = ? AND IsDeleted = 0 LIMIT 1", [id]);
|
||||
if (dbParty == null || dbParty.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
|
@ -15,8 +15,24 @@ export default class PartyRepo {
|
|||
}
|
||||
}
|
||||
|
||||
public static async selectByUserId(userId:number) {
|
||||
const dbParties = await Database.Instance.query("SELECT Party.* FROM Party JOIN UserParty ON Party.Id = UserParty.PartyId WHERE UserParty.UserId = ? AND UserParty.IsDeleted = 0 AND Party.IsDeleted = 0", [userId]);
|
||||
const parties = new Array<Party>();
|
||||
if (dbParties == null || dbParties.length === 0) {
|
||||
return parties;
|
||||
} else {
|
||||
for (const dbParty of dbParties) {
|
||||
const party = new Party();
|
||||
populatePartyFromDB(party, dbParty);
|
||||
parties.push(party);
|
||||
}
|
||||
|
||||
return parties;
|
||||
}
|
||||
}
|
||||
|
||||
public static async selectByPartyRef(partyRef:string) {
|
||||
const dbParty = await Database.Instance.query("SELECT * FROM Party WHERE PartyRef = ? LIMIT 1", [partyRef]);
|
||||
const dbParty = await Database.Instance.query("SELECT * FROM Party WHERE PartyRef = ? AND IsDeleted = 0 LIMIT 1", [partyRef]);
|
||||
if (dbParty == null || dbParty.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
|
@ -26,14 +42,14 @@ export default class PartyRepo {
|
|||
}
|
||||
}
|
||||
|
||||
public static async insertUpdate(user:User) {
|
||||
if (user.Id === Number.MIN_VALUE) {
|
||||
await Database.Instance.query("INSERT Party (Username, PasswordHash, PasswordSalt, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
|
||||
user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted)
|
||||
public static async insertUpdate(party:Party) {
|
||||
if (party.Id === Number.MIN_VALUE) {
|
||||
await Database.Instance.query("INSERT Party (Name, PartyRef, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [
|
||||
party.Name, party.PartyRef, party.CreatedByUserId, party.CreatedDatetime.getTime(), party.LastModifiedByUserId ?? null, party.LastModifiedDatetime?.getTime() ?? null, party.DeletedByUserId ?? null, party.DeletedDatetime?.getTime() ?? null, Number(party.IsDeleted)
|
||||
]);
|
||||
} else {
|
||||
await Database.Instance.query(`UPDATE Party SET Username = ?, PasswordHash = ?, PasswordSalt = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ?, WHERE Id = ?`, [
|
||||
user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted), user.Id
|
||||
await Database.Instance.query(`UPDATE Party SET Name = ?, PartyRef = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ?, WHERE Id = ?`, [
|
||||
party.Name, party.PartyRef, party.CreatedByUserId, party.CreatedDatetime.getTime(), party.LastModifiedByUserId ?? null, party.LastModifiedDatetime?.getTime() ?? null, party.DeletedByUserId ?? null, party.DeletedDatetime?.getTime() ?? null, Number(party.IsDeleted), party.Id
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
57
server/repos/UserPartyRepo.ts
Normal file
57
server/repos/UserPartyRepo.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import Database from "../objects/Database";
|
||||
import UserParty from "../objects/UserParty";
|
||||
import RepoBase from "./RepoBase";
|
||||
|
||||
export default class UserPartyRepo {
|
||||
public static async selectById(id:number) {
|
||||
const dbUserParty = await Database.Instance.query("SELECT * FROM UserParty WHERE Id = ? AND IsDeleted = 0 LIMIT 1", [id]);
|
||||
if (dbUserParty == null || dbUserParty.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
const userParty = new UserParty();
|
||||
populateUserPartyFromDB(userParty, dbUserParty[0]);
|
||||
return userParty;
|
||||
}
|
||||
}
|
||||
|
||||
public static async selectByUserId(userId:number) {
|
||||
const dbUserParties = await Database.Instance.query("SELECT * FROM UserParty WHERE UserId = ? AND IsDeleted = 0", [userId]);
|
||||
if (dbUserParties == null || dbUserParties.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
const userParties = new Array<UserParty>();
|
||||
for (const dbUserParty of dbUserParties) {
|
||||
const userParty = new UserParty();
|
||||
populateUserPartyFromDB(userParty, dbUserParty[0]);
|
||||
userParties.push(userParty);
|
||||
}
|
||||
|
||||
return userParties;
|
||||
}
|
||||
}
|
||||
|
||||
public static async insertUpdate(userParty:UserParty) {
|
||||
if (userParty.Id === Number.MIN_VALUE) {
|
||||
await Database.Instance.query("INSERT UserParty (UserId, PartyId, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [
|
||||
userParty.UserId, userParty.PartyId, userParty.CreatedByUserId, userParty.CreatedDatetime.getTime(), userParty.LastModifiedByUserId ?? null, userParty.LastModifiedDatetime?.getTime() ?? null, userParty.DeletedByUserId ?? null, userParty.DeletedDatetime?.getTime() ?? null, Number(userParty.IsDeleted)
|
||||
]);
|
||||
} else {
|
||||
await Database.Instance.query(`UPDATE UserParty SET UserId = ?, PartyId = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ?, WHERE Id = ?`, [
|
||||
userParty.UserId, userParty.PartyId, userParty.CreatedByUserId, userParty.CreatedDatetime.getTime(), userParty.LastModifiedByUserId ?? null, userParty.LastModifiedDatetime?.getTime() ?? null, userParty.DeletedByUserId ?? null, userParty.DeletedDatetime?.getTime() ?? null, Number(userParty.IsDeleted), userParty.Id
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function populateUserPartyFromDB(userParty:UserParty, dbUserParty:any) {
|
||||
userParty.Id = dbUserParty.Id;
|
||||
userParty.UserId = dbUserParty.UserId;
|
||||
userParty.PartyId = dbUserParty.PartyId;
|
||||
userParty.CreatedByUserId = dbUserParty.CreatedByUserId;
|
||||
userParty.CreatedDatetime = new Date(dbUserParty.CreatedDatetime);
|
||||
userParty.LastModifiedByUserId = dbUserParty.LastModifiedByUserId;
|
||||
userParty.LastModifiedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbUserParty.LastModifiedDatetime);
|
||||
userParty.DeletedByUserId = dbUserParty.DeletedByUserId;
|
||||
userParty.DeletedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbUserParty.DeletedDatetime);
|
||||
userParty.IsDeleted = dbUserParty.IsDeleted;
|
||||
}
|
|
@ -3,6 +3,9 @@ import User from "../objects/User";
|
|||
import PartyRepo from "../repos/PartyRepo";
|
||||
import UserRepo from "../repos/UserRepo";
|
||||
import PasswordUtility from "../utilities/PasswordUtility";
|
||||
import Party from "../objects/Party";
|
||||
import UserParty from "../objects/UserParty";
|
||||
import UserPartyRepo from "../repos/UserPartyRepo";
|
||||
|
||||
export default class UserService {
|
||||
public static async GetUser(id:number) {
|
||||
|
@ -10,6 +13,7 @@ export default class UserService {
|
|||
return await UserRepo.selectById(id);
|
||||
} catch (e) {
|
||||
Console.printError(`MultiProbe server service error:\n${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -18,6 +22,16 @@ export default class UserService {
|
|||
return await UserRepo.selectByUsername(username);
|
||||
} catch (e) {
|
||||
Console.printError(`MultiProbe server service error:\n${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public static async GetUserParties(userId:number) {
|
||||
try {
|
||||
return await PartyRepo.selectByUserId(userId);
|
||||
} catch (e) {
|
||||
Console.printError(`MultiProbe server service error:\n${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -26,6 +40,7 @@ export default class UserService {
|
|||
return await PartyRepo.selectById(id);
|
||||
} catch (e) {
|
||||
Console.printError(`MultiProbe server service error:\n${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -34,6 +49,7 @@ export default class UserService {
|
|||
return await PartyRepo.selectByPartyRef(partyRef);
|
||||
} catch (e) {
|
||||
Console.printError(`MultiProbe server service error:\n${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -49,6 +65,35 @@ export default class UserService {
|
|||
await UserRepo.insertUpdate(user);
|
||||
} catch (e) {
|
||||
Console.printError(`MultiProbe server service error:\n${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public static async CreateParty(currentUserId:number, name:string, partyRef:string) {
|
||||
try {
|
||||
const party = new Party();
|
||||
party.Name = name;
|
||||
party.PartyRef = partyRef;
|
||||
party.CreatedByUserId = currentUserId;
|
||||
party.CreatedDatetime = new Date();
|
||||
|
||||
await PartyRepo.insertUpdate(party);
|
||||
|
||||
const newParty = await PartyRepo.selectByPartyRef(partyRef);
|
||||
if (!newParty) {
|
||||
throw "This shouldn't happen";
|
||||
}
|
||||
|
||||
const userParty = new UserParty();
|
||||
userParty.UserId = currentUserId;
|
||||
userParty.PartyId = newParty.Id;
|
||||
userParty.CreatedByUserId = currentUserId;
|
||||
userParty.CreatedDatetime = new Date();
|
||||
|
||||
await UserPartyRepo.insertUpdate(userParty);
|
||||
} catch (e) {
|
||||
Console.printError(`MultiProbe server service error:\n${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,8 +9,8 @@
|
|||
<a class="btn btn-primary btn-lg me-2" href="/account/password">Change Password</a>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a class="btn btn-primary btn-lg me-2" href="/account/password">Create Party</a>
|
||||
<a class="btn btn-primary btn-lg me-2" href="/account/username">Join Party</a>
|
||||
<a class="btn btn-primary btn-lg me-2" href="/party/create">Create Party</a>
|
||||
<a class="btn btn-primary btn-lg me-2" href="/party/join">Join Party</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -21,9 +21,26 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Code</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for (const party of parties) { %>
|
||||
<tr>
|
||||
<td><%= party.Name %></td>
|
||||
<td><%= party.PartyRef %></td>
|
||||
<td class="text-end">
|
||||
<a href="/party/setactive?id=<%= party.Id %>" class="btn btn-sm btn-success me-2">Set Active</a>
|
||||
<% if (party.CreatedByUserId === user.Id) { %>
|
||||
<a class="btn btn-sm btn-danger disabled" title="You may not leave a party if you created it." onclick="alert('You may not leave a party if you created it.')">Leave</a>
|
||||
<% } else { %>
|
||||
<a href="/party/leave?id=<%= party.Id %>" class="btn btn-sm btn-danger" onclick="return confirm(`Are you sure you want to leave '<%= party.Name %>'?`)">Leave</a>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } else { %>
|
||||
<div class="alert alert-primary" role="alert">You are not in any parties.</div>
|
||||
|
|
28
server/templates/party/createedit.ejs
Normal file
28
server/templates/party/createedit.ejs
Normal file
|
@ -0,0 +1,28 @@
|
|||
<%- include("../base/header", { title: typeof(party) === "undefined" ? "Create Party" : `Editing ${party.Name}` }) %>
|
||||
<% if (typeof(party) === "undefined") { %>
|
||||
<h1 class="text-center mb-5">Create Party</h1>
|
||||
<% } else { %>
|
||||
<h1 class="text-center mb-5">Editing <%= party.Name %></h1>
|
||||
<% } %>
|
||||
<form method="post">
|
||||
<div class="row">
|
||||
<div class="col-4"></div>
|
||||
<div class="col-4">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Party Name</label>
|
||||
<input class="form-control" type="text" name="partyName" maxlength="64" required />
|
||||
</div>
|
||||
<div class="form-group mt-3">
|
||||
<label class="form-label">Join Code / Party ID<br><span style="font-size: 10pt;">Pick something nice, e.g. "<b>3EGGS</b>"</span></label>
|
||||
<input class="form-control" type="text" name="partyRef" minlength="5" maxlength="5" required />
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-5">
|
||||
<input class="btn btn-primary" type="submit" value="Save" />
|
||||
<a class="btn btn-danger ms-2" href="/">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4"></div>
|
||||
</div>
|
||||
</form>
|
||||
<%- include("../base/footer") %>
|
20
server/templates/party/join.ejs
Normal file
20
server/templates/party/join.ejs
Normal file
|
@ -0,0 +1,20 @@
|
|||
<%- include("../base/header", { title: "Join Party" }) %>
|
||||
<h1 class="text-center mb-5">Join Party</h1>
|
||||
<form method="post">
|
||||
<div class="row">
|
||||
<div class="col-4"></div>
|
||||
<div class="col-4">
|
||||
<div class="form-group mt-3">
|
||||
<label class="form-label">Join Code / Party ID <span style="font-size: 10pt;">e.g. "<b>3EGGS</b>"</span></label>
|
||||
<input class="form-control" type="text" name="partyRef" minlength="5" maxlength="5" required />
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-5">
|
||||
<input class="btn btn-primary" type="submit" value="Join" />
|
||||
<a class="btn btn-danger ms-2" href="/">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4"></div>
|
||||
</div>
|
||||
</form>
|
||||
<%- include("../base/footer") %>
|
Loading…
Reference in a new issue