This commit is contained in:
@@ -13,6 +13,23 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install SSH client
|
||||||
|
run: |
|
||||||
|
apt-get update -y && apt-get install -y openssh-client
|
||||||
|
|
||||||
|
- name: Setup SSH inside container
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
|
||||||
|
# Add the container host to known_hosts
|
||||||
|
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.aranroig.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: |
|
run: |
|
||||||
docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/dragonroll-frontend:latest ./frontend
|
docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/dragonroll-frontend:latest ./frontend
|
||||||
@@ -23,17 +40,17 @@ jobs:
|
|||||||
docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/dragonroll-backend:latest ./backend
|
docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/dragonroll-backend:latest ./backend
|
||||||
docker push git.aranroig.com/${{ secrets.REGISTRY_USER }}/dragonroll-backend:latest
|
docker push git.aranroig.com/${{ secrets.REGISTRY_USER }}/dragonroll-backend:latest
|
||||||
|
|
||||||
# - name: Copy files
|
- name: Copy files
|
||||||
# run: |
|
run: |
|
||||||
# scp docker-compose.yml deploy@${{ secrets.DEPLOY_HOST}}:/var/www/app/
|
scp docker-compose.yml deploy@${{ secrets.DEPLOY_HOST}}:/var/www/app/
|
||||||
# scp nginx.conf deploy@${{ secrets.DEPLOY_HOST }}:/var/www/app/nginx.conf
|
scp nginx.conf deploy@${{ secrets.DEPLOY_HOST }}:/var/www/app/nginx.conf
|
||||||
|
|
||||||
#- name: Deploy
|
- name: Deploy
|
||||||
# run: |
|
run: |
|
||||||
# ssh deploy@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
ssh deploy@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
||||||
# echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.aranroig.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.aranroig.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
# cd /var/www/app/
|
cd /var/www/app/
|
||||||
# docker-compose pull
|
docker-compose pull
|
||||||
# docker-compose up -d
|
docker-compose up -d
|
||||||
# EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:latest
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: git.aranroig.com/syndria98/dragonroll-frontend:latest
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: git.aranroig.com/syndria98/dragonroll-backend:latest
|
||||||
|
restart: always
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMounted, onUnmounted, ref, createApp } from 'vue';
|
import { onMounted, onUnmounted, ref, createApp } from 'vue';
|
||||||
import ToastManager from '~/components/managers/ToastManager.vue';
|
import ToastManager from '~/components/managers/ToastManager.vue';
|
||||||
import { emitter } from '~/services/Emitter';
|
import { emitter } from '~/services/Emitter';
|
||||||
import { ParseMarkdown } from '~/services/Marker';
|
import { GetWidget, ParseMarkdown } from '~/services/Marker';
|
||||||
import Server from '~/services/Server';
|
import Server from '~/services/Server';
|
||||||
import { DisplayToast } from '~/services/Toaster';
|
import { DisplayToast } from '~/services/Toaster';
|
||||||
import TestWidget from '../widgets/TestWidget.vue';
|
import TestWidget from '../widgets/TestWidget.vue';
|
||||||
@@ -15,6 +15,9 @@ const sourceText = ref(''); // Original markdown source, used for editing
|
|||||||
const displayText = ref(''); // Compiled HTML from markdown
|
const displayText = ref(''); // Compiled HTML from markdown
|
||||||
|
|
||||||
const editingMode = ref(false);
|
const editingMode = ref(false);
|
||||||
|
const editableTitle = ref(null);
|
||||||
|
const title = ref(props.title);
|
||||||
|
const displayTitle = ref('');
|
||||||
|
|
||||||
function closeNote(){
|
function closeNote(){
|
||||||
emitter.emit('delete-note', props.noteKey);
|
emitter.emit('delete-note', props.noteKey);
|
||||||
@@ -28,12 +31,9 @@ function mountComponents() {
|
|||||||
const nodes = document.querySelectorAll('.vue-component');
|
const nodes = document.querySelectorAll('.vue-component');
|
||||||
|
|
||||||
nodes.forEach(el => {
|
nodes.forEach(el => {
|
||||||
const app = createApp(TestWidget, { content: el.dataset.content });
|
const app = createApp(GetWidget(el.dataset.component), { content: el.dataset.content });
|
||||||
app.mount(el);
|
app.mount(el);
|
||||||
console.log("Mounted a component")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Huh")
|
|
||||||
}
|
}
|
||||||
///
|
///
|
||||||
|
|
||||||
@@ -51,6 +51,8 @@ watch(sourceText, (newText) => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
sourceText.value = props.text;
|
sourceText.value = props.text;
|
||||||
|
title.value = props.title;
|
||||||
|
displayTitle.value = props.title;
|
||||||
// window.addEventListener('keydown', handleKeydown);
|
// window.addEventListener('keydown', handleKeydown);
|
||||||
setTimeout(() => setupCallout(), 0);
|
setTimeout(() => setupCallout(), 0);
|
||||||
update();
|
update();
|
||||||
@@ -65,7 +67,10 @@ function handleKeydown(e) {
|
|||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'e') {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'e') {
|
||||||
e.preventDefault(); // prevent browser default behavior
|
e.preventDefault(); // prevent browser default behavior
|
||||||
editingMode.value = !editingMode.value;
|
editingMode.value = !editingMode.value;
|
||||||
if(!editingMode.value) update();
|
if(!editingMode.value){
|
||||||
|
update();
|
||||||
|
SaveNote(); // Save when switching to display mode
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,13 +85,14 @@ function handleKeydown(e) {
|
|||||||
function SaveNote(){
|
function SaveNote(){
|
||||||
Server().post('/note/update', {
|
Server().post('/note/update', {
|
||||||
id: props.noteKey,
|
id: props.noteKey,
|
||||||
content: sourceText.value
|
content: sourceText.value,
|
||||||
|
title: title.value,
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
if(response.data.status !== 'ok'){
|
if(response.data.status !== 'ok'){
|
||||||
// Handle error (e.g., show a notification)
|
// Handle error (e.g., show a notification)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DisplayToast('green', "Note saved successfully.", 500);
|
// DisplayToast('green', "Note saved successfully.", 500);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
// Handle error (e.g., show a notification)
|
// Handle error (e.g., show a notification)
|
||||||
});
|
});
|
||||||
@@ -138,6 +144,10 @@ function setupCallout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const editTitle = (e) => {
|
||||||
|
title.value = e.target.innerText;
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -150,7 +160,11 @@ function setupCallout() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="note-content-container">
|
<div class="note-content-container">
|
||||||
<textarea v-model="sourceText" class="full-editor" v-if="editingMode"></textarea>
|
<textarea v-model="sourceText" class="full-editor" v-if="editingMode"></textarea>
|
||||||
<div class="note-content" ref="noteContent" v-html="displayText" v-else></div>
|
<div v-else class="note-content" ref="noteContent">
|
||||||
|
<h1 contenteditable="true" ref="editableTitle" @input="editTitle">{{ displayTitle }}</h1>
|
||||||
|
<div ref="noteContent" v-html="displayText"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
59
frontend/app/components/viewer/widgets/RollWidget.vue
Normal file
59
frontend/app/components/viewer/widgets/RollWidget.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps(['content']);
|
||||||
|
|
||||||
|
import { parse } from '~/services/widgets/DiceParser';
|
||||||
|
import { AddSound } from '~/services/Sound';
|
||||||
|
|
||||||
|
const container = ref(null);
|
||||||
|
const resultText = ref("");
|
||||||
|
|
||||||
|
const rollDice = () => {
|
||||||
|
console.log(props.content);
|
||||||
|
const result = parse(props.content);
|
||||||
|
console.log(result);
|
||||||
|
resultText.value = result.total;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
AddSound(container.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="roll-widget" ref="container">
|
||||||
|
<div class="roll-widget-body">
|
||||||
|
<button class="btn-primary btn-inline sound-click" @click="rollDice">
|
||||||
|
<span class="dice-content">
|
||||||
|
<!-- Dice icon (SVG) -->
|
||||||
|
<img class="icon" src="/icons/iconoir/regular/dice-three.svg" draggable="false">
|
||||||
|
|
||||||
|
<!-- Result text -->
|
||||||
|
<span class="result-text">
|
||||||
|
{{ resultText || props.content }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.roll-widget {
|
||||||
|
display: inline-flex; /* or inline-block */
|
||||||
|
vertical-align: middle; /* keeps it aligned nicely with text */
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-inline {
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dice-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,7 @@ const wrapper = ref(null);
|
|||||||
|
|
||||||
const props = defineProps(['data']);
|
const props = defineProps(['data']);
|
||||||
const data = props.data;
|
const data = props.data;
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
let id = data.id;
|
let id = data.id;
|
||||||
|
|
||||||
@@ -29,11 +30,13 @@ const colorPicker = ref(null);
|
|||||||
function NewCampaign(){
|
function NewCampaign(){
|
||||||
const color = colorPicker.value.GetColor();
|
const color = colorPicker.value.GetColor();
|
||||||
console.log(color);
|
console.log(color);
|
||||||
|
loading.value = true;
|
||||||
Server().post('/campaign/create', {
|
Server().post('/campaign/create', {
|
||||||
name: campaignName.value,
|
name: campaignName.value,
|
||||||
description: campaignDescription.value,
|
description: campaignDescription.value,
|
||||||
color: colorPicker.value.GetColor(),
|
color: colorPicker.value.GetColor(),
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
|
loading.value = false;
|
||||||
console.log(response.data);
|
console.log(response.data);
|
||||||
DisplayToast('green', $t('campaigns.create.success'), 3000);
|
DisplayToast('green', $t('campaigns.create.success'), 3000);
|
||||||
ClearWindow({id});
|
ClearWindow({id});
|
||||||
@@ -63,7 +66,12 @@ function NewCampaign(){
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn-primary sound-click">
|
<button class="btn-primary sound-click">
|
||||||
|
<span v-if="loading">
|
||||||
|
<Spinner />
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
{{ $t("general.create") }}
|
{{ $t("general.create") }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import { Marked } from "marked";
|
import { Marked } from "marked";
|
||||||
|
const widget_map = {
|
||||||
const marker = new Marked();
|
|
||||||
|
|
||||||
// optional: use a shared marked instance inside renderer
|
|
||||||
const renderMarkdown = (text) => marker.parse(text);
|
|
||||||
|
|
||||||
const WidgetMap = {
|
|
||||||
test: () => import("~/components/viewer/widgets/TestWidget.vue"),
|
test: () => import("~/components/viewer/widgets/TestWidget.vue"),
|
||||||
table: () => import("~/components/viewer/widgets/TableWidget.vue"),
|
table: () => import("~/components/viewer/widgets/TableWidget.vue"),
|
||||||
|
roll: () => import("~/components/viewer/widgets/RollWidget.vue"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const componentCache = {}
|
||||||
|
|
||||||
|
const GetWidget = (type) => {
|
||||||
|
if (!componentCache[type]) {
|
||||||
|
componentCache[type] = defineAsyncComponent(
|
||||||
|
widget_map[type]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return componentCache[type]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const marker = new Marked();
|
||||||
|
|
||||||
const extension = {
|
const extension = {
|
||||||
name: "something",
|
name: "something",
|
||||||
level: "block",
|
level: "block",
|
||||||
@@ -33,12 +43,37 @@ const extension = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inlineExtension = {
|
||||||
|
name: "something_inline",
|
||||||
|
level: "inline",
|
||||||
|
start(src) {
|
||||||
|
return src.indexOf("@");
|
||||||
|
},
|
||||||
|
|
||||||
|
tokenizer(src) {
|
||||||
|
const rule = /^@(\w+)\s*\[([^\]]*)\]/;
|
||||||
|
const match = rule.exec(src);
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "something_inline",
|
||||||
|
raw: match[0],
|
||||||
|
name: match[1],
|
||||||
|
text: match[2],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer(token) {
|
||||||
|
return `<span class="vue-component" data-component="${token.name}" data-content="${token.text}"></span>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
marker.use({
|
marker.use({
|
||||||
extensions: [extension],
|
extensions: [extension, inlineExtension],
|
||||||
});
|
});
|
||||||
|
|
||||||
function ParseMarkdown(source) {
|
function ParseMarkdown(source) {
|
||||||
return marker.parse(source || "");
|
return marker.parse(source || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ParseMarkdown, WidgetMap };
|
export { ParseMarkdown, GetWidget };
|
||||||
130
frontend/app/services/widgets/DiceParser.js
Normal file
130
frontend/app/services/widgets/DiceParser.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// dice-parser.js
|
||||||
|
|
||||||
|
function roll(sides) {
|
||||||
|
return Math.floor(Math.random() * sides) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(expr) {
|
||||||
|
const re = /(\d*d\d+(?:adv|dis|kh\d+|kl\d+)?|\d+|[+\-*\/()])/gi;
|
||||||
|
const tokens = [];
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(expr)) !== null) tokens.push(m[0].toLowerCase());
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiceToken(tok) {
|
||||||
|
const m = tok.match(/^(\d*)d(\d+)(adv|dis|kh(\d+)|kl(\d+))?$/i);
|
||||||
|
if (!m) return null;
|
||||||
|
const count = parseInt(m[1] || '1');
|
||||||
|
const sides = parseInt(m[2]);
|
||||||
|
const mod = (m[3] || '').toLowerCase();
|
||||||
|
if (count < 1 || count > 1000 || sides < 2 || sides > 10000)
|
||||||
|
throw new Error(`Invalid dice: ${tok}`);
|
||||||
|
return { count, sides, mod };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse(expr) {
|
||||||
|
const tokens = tokenize(expr);
|
||||||
|
if (!tokens.length) throw new Error('Empty expression');
|
||||||
|
let pos = 0;
|
||||||
|
const rolls = [];
|
||||||
|
|
||||||
|
function peek() { return tokens[pos]; }
|
||||||
|
function consume() { return tokens[pos++]; }
|
||||||
|
|
||||||
|
function parseExpr() { return parseAddSub(); }
|
||||||
|
|
||||||
|
function parseAddSub() {
|
||||||
|
let left = parseMulDiv();
|
||||||
|
while (peek() === '+' || peek() === '-') {
|
||||||
|
const op = consume();
|
||||||
|
const right = parseMulDiv();
|
||||||
|
left = {
|
||||||
|
value: op === '+' ? left.value + right.value : left.value - right.value,
|
||||||
|
rolls: [...left.rolls, ...right.rolls],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMulDiv() {
|
||||||
|
let left = parseUnary();
|
||||||
|
while (peek() === '*' || peek() === '/') {
|
||||||
|
const op = consume();
|
||||||
|
const right = parseUnary();
|
||||||
|
if (op === '/' && right.value === 0) throw new Error('Division by zero');
|
||||||
|
left = {
|
||||||
|
value: op === '*' ? left.value * right.value : Math.floor(left.value / right.value),
|
||||||
|
rolls: [...left.rolls, ...right.rolls],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUnary() {
|
||||||
|
if (peek() === '-') {
|
||||||
|
consume();
|
||||||
|
const r = parsePrimary();
|
||||||
|
return { value: -r.value, rolls: r.rolls };
|
||||||
|
}
|
||||||
|
return parsePrimary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrimary() {
|
||||||
|
const tok = peek();
|
||||||
|
if (!tok) throw new Error('Unexpected end of expression');
|
||||||
|
|
||||||
|
if (tok === '(') {
|
||||||
|
consume();
|
||||||
|
const inner = parseExpr();
|
||||||
|
if (peek() !== ')') throw new Error('Missing closing )');
|
||||||
|
consume();
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diceInfo = parseDiceToken(tok);
|
||||||
|
if (diceInfo) {
|
||||||
|
consume();
|
||||||
|
return rollDice(diceInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(tok)) {
|
||||||
|
consume();
|
||||||
|
return { value: parseInt(tok), rolls: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected token: ${tok}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollDice({ count, sides, mod }) {
|
||||||
|
let rawRolls, kept;
|
||||||
|
|
||||||
|
if (mod === 'adv') {
|
||||||
|
rawRolls = [roll(sides), roll(sides)];
|
||||||
|
kept = [Math.max(...rawRolls)];
|
||||||
|
} else if (mod === 'dis') {
|
||||||
|
rawRolls = [roll(sides), roll(sides)];
|
||||||
|
kept = [Math.min(...rawRolls)];
|
||||||
|
} else if (mod.startsWith('kh')) {
|
||||||
|
const k = parseInt(mod.slice(2));
|
||||||
|
rawRolls = Array.from({ length: count }, () => roll(sides));
|
||||||
|
kept = [...rawRolls].sort((a, b) => b - a).slice(0, k);
|
||||||
|
} else if (mod.startsWith('kl')) {
|
||||||
|
const k = parseInt(mod.slice(2));
|
||||||
|
rawRolls = Array.from({ length: count }, () => roll(sides));
|
||||||
|
kept = [...rawRolls].sort((a, b) => a - b).slice(0, k);
|
||||||
|
} else {
|
||||||
|
rawRolls = Array.from({ length: count }, () => roll(sides));
|
||||||
|
kept = rawRolls.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = { sides, rawRolls, kept, mod, value: kept.reduce((a, b) => a + b, 0) };
|
||||||
|
rolls.push(entry);
|
||||||
|
return { value: entry.value, rolls: [entry] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseExpr();
|
||||||
|
if (pos < tokens.length) throw new Error(`Unexpected token: ${tokens[pos]}`);
|
||||||
|
|
||||||
|
return { total: result.value, rolls: result.rolls };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user