mirror of
https://github.com/zadam/trilium.git
synced 2025-10-20 23:29:02 +02:00
feat(react/ribbon): port note paths tab
This commit is contained in:
parent
8287063aab
commit
8f69b87dd1
@ -1,7 +1,7 @@
|
|||||||
import { ActionKeyboardShortcut, KeyboardActionNames } from "@triliumnext/commons";
|
import { ActionKeyboardShortcut, KeyboardActionNames } from "@triliumnext/commons";
|
||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useState } from "preact/hooks";
|
||||||
import keyboard_actions from "../../services/keyboard_actions";
|
import keyboard_actions from "../../services/keyboard_actions";
|
||||||
import { separateByCommas } from "./react_utils";
|
import { joinElements } from "./react_utils";
|
||||||
|
|
||||||
interface KeyboardShortcutProps {
|
interface KeyboardShortcutProps {
|
||||||
actionName: KeyboardActionNames;
|
actionName: KeyboardActionNames;
|
||||||
@ -22,7 +22,7 @@ export default function KeyboardShortcut({ actionName }: KeyboardShortcutProps)
|
|||||||
<>
|
<>
|
||||||
{action.effectiveShortcuts?.map((shortcut, i) => {
|
{action.effectiveShortcuts?.map((shortcut, i) => {
|
||||||
const keys = shortcut.split("+");
|
const keys = shortcut.split("+");
|
||||||
return separateByCommas(keys
|
return joinElements(keys
|
||||||
.map((key, i) => (
|
.map((key, i) => (
|
||||||
<>
|
<>
|
||||||
<kbd>{key}</kbd> {i + 1 < keys.length && "+ "}
|
<kbd>{key}</kbd> {i + 1 < keys.length && "+ "}
|
||||||
|
@ -6,9 +6,10 @@ interface NoteLinkOpts {
|
|||||||
notePath: string | string[];
|
notePath: string | string[];
|
||||||
showNotePath?: boolean;
|
showNotePath?: boolean;
|
||||||
style?: Record<string, string | number>;
|
style?: Record<string, string | number>;
|
||||||
|
noPreview?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NoteLink({ notePath, showNotePath, style }: NoteLinkOpts) {
|
export default function NoteLink({ notePath, showNotePath, style, noPreview }: NoteLinkOpts) {
|
||||||
const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
|
const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
|
||||||
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
|
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
|
||||||
|
|
||||||
@ -21,6 +22,13 @@ export default function NoteLink({ notePath, showNotePath, style }: NoteLinkOpts
|
|||||||
jqueryEl?.css(style);
|
jqueryEl?.css(style);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const $linkEl = jqueryEl?.find("a");
|
||||||
|
if (noPreview) {
|
||||||
|
$linkEl?.addClass("no-tooltip-preview");
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkEl?.addClass("tn-link");
|
||||||
|
|
||||||
return <RawHtml html={jqueryEl} />
|
return <RawHtml html={jqueryEl} />
|
||||||
|
|
||||||
}
|
}
|
@ -43,7 +43,7 @@ export function disposeReactWidget(container: Element) {
|
|||||||
render(null, container);
|
render(null, container);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function separateByCommas(components: ComponentChild[]) {
|
export function joinElements(components: ComponentChild[], separator = ", ") {
|
||||||
return components.reduce<any>((acc, item) =>
|
return components.reduce<any>((acc, item) =>
|
||||||
(acc.length ? [...acc, ", ", item] : [item]), []);
|
(acc.length ? [...acc, separator, item] : [item]), []);
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import server from "../../services/server";
|
|||||||
import { t } from "../../services/i18n";
|
import { t } from "../../services/i18n";
|
||||||
import froca from "../../services/froca";
|
import froca from "../../services/froca";
|
||||||
import NoteLink from "../react/NoteLink";
|
import NoteLink from "../react/NoteLink";
|
||||||
import { separateByCommas } from "../react/react_utils";
|
import { joinElements } from "../react/react_utils";
|
||||||
|
|
||||||
export default function EditedNotesTab({ note }: TabContext) {
|
export default function EditedNotesTab({ note }: TabContext) {
|
||||||
const [ editedNotes, setEditedNotes ] = useState<EditedNotesResponse>();
|
const [ editedNotes, setEditedNotes ] = useState<EditedNotesResponse>();
|
||||||
@ -29,7 +29,7 @@ export default function EditedNotesTab({ note }: TabContext) {
|
|||||||
}}>
|
}}>
|
||||||
{editedNotes ? (
|
{editedNotes ? (
|
||||||
<div className="edited-notes-list use-tn-links">
|
<div className="edited-notes-list use-tn-links">
|
||||||
{separateByCommas(editedNotes.map(editedNote => {
|
{joinElements(editedNotes.map(editedNote => {
|
||||||
return (
|
return (
|
||||||
<span className="edited-note-line">
|
<span className="edited-note-line">
|
||||||
{editedNote.isDeleted ? (
|
{editedNote.isDeleted ? (
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "preact/hooks";
|
|
||||||
import { t } from "../../services/i18n";
|
import { t } from "../../services/i18n";
|
||||||
import { formatSize } from "../../services/utils";
|
import { formatSize } from "../../services/utils";
|
||||||
import FormFileUpload, { FormFileUploadButton } from "../react/FormFileUpload";
|
import { FormFileUploadButton } from "../react/FormFileUpload";
|
||||||
import { useNoteBlob, useNoteLabel, useTriliumEventBeta } from "../react/hooks";
|
import { useNoteBlob, useNoteLabel } from "../react/hooks";
|
||||||
import { TabContext } from "./ribbon-interface";
|
import { TabContext } from "./ribbon-interface";
|
||||||
import FBlob from "../../entities/fblob";
|
|
||||||
import Button from "../react/Button";
|
import Button from "../react/Button";
|
||||||
import protected_session_holder from "../../services/protected_session_holder";
|
import protected_session_holder from "../../services/protected_session_holder";
|
||||||
import { downloadFileNote, openNoteExternally } from "../../services/open";
|
import { downloadFileNote, openNoteExternally } from "../../services/open";
|
||||||
|
107
apps/client/src/widgets/ribbon/NotePathsTab.tsx
Normal file
107
apps/client/src/widgets/ribbon/NotePathsTab.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { TabContext } from "./ribbon-interface";
|
||||||
|
import { t } from "../../services/i18n";
|
||||||
|
import Button from "../react/Button";
|
||||||
|
import { useTriliumEventBeta } from "../react/hooks";
|
||||||
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||||
|
import { NotePathRecord } from "../../entities/fnote";
|
||||||
|
import NoteLink from "../react/NoteLink";
|
||||||
|
import { joinElements } from "../react/react_utils";
|
||||||
|
|
||||||
|
export default function NotePathsTab({ note, hoistedNoteId, notePath }: TabContext) {
|
||||||
|
const [ sortedNotePaths, setSortedNotePaths ] = useState<NotePathRecord[]>();
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!note) return;
|
||||||
|
setSortedNotePaths(note
|
||||||
|
.getSortedNotePathRecords(hoistedNoteId)
|
||||||
|
.filter((notePath) => !notePath.isHidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(refresh, [ note?.noteId ]);
|
||||||
|
useTriliumEventBeta("entitiesReloaded", ({ loadResults }) => {
|
||||||
|
const noteId = note?.noteId;
|
||||||
|
if (!noteId) return;
|
||||||
|
if (loadResults.getBranchRows().find((branch) => branch.noteId === noteId)
|
||||||
|
|| loadResults.isNoteReloaded(noteId)) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="note-paths-widget">
|
||||||
|
{sortedNotePaths && (
|
||||||
|
<>
|
||||||
|
<div className="note-path-intro">
|
||||||
|
{sortedNotePaths.length > 0 ? t("note_paths.intro_placed") : t("note_paths.intro_not_placed")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="note-path-list">
|
||||||
|
{sortedNotePaths.map(sortedNotePath => (
|
||||||
|
<NotePath
|
||||||
|
currentNotePath={notePath}
|
||||||
|
notePathRecord={sortedNotePath}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
triggerCommand="cloneNoteIdsTo"
|
||||||
|
text={t("note_paths.clone_button")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotePath({ currentNotePath, notePathRecord }: { currentNotePath: string, notePathRecord?: NotePathRecord }) {
|
||||||
|
const notePath = notePathRecord?.notePath ?? [];
|
||||||
|
const notePathString = useMemo(() => notePath.join("/"), [ notePath ]);
|
||||||
|
|
||||||
|
const [ classes, icons ] = useMemo(() => {
|
||||||
|
const classes: string[] = [];
|
||||||
|
const icons: { icon: string, title: string }[] = [];
|
||||||
|
|
||||||
|
if (notePathString === currentNotePath) {
|
||||||
|
classes.push("path-current");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!notePathRecord || notePathRecord.isInHoistedSubTree) {
|
||||||
|
classes.push("path-in-hoisted-subtree");
|
||||||
|
} else {
|
||||||
|
icons.push({ icon: "bx bx-trending-up", title: t("note_paths.outside_hoisted") })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notePathRecord?.isArchived) {
|
||||||
|
classes.push("path-archived");
|
||||||
|
icons.push({ icon: "bx bx-archive", title: t("note_paths.archived") })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notePathRecord?.isSearch) {
|
||||||
|
classes.push("path-search");
|
||||||
|
icons.push({ icon: "bx bx-search", title: t("note_paths.search") })
|
||||||
|
}
|
||||||
|
|
||||||
|
return [ classes.join(" "), icons ];
|
||||||
|
}, [ notePathString, currentNotePath, notePathRecord ]);
|
||||||
|
|
||||||
|
// Determine the full note path (for the links) of every component of the current note path.
|
||||||
|
const pathSegments: string[] = [];
|
||||||
|
const fullNotePaths: string[] = [];
|
||||||
|
for (const noteId of notePath) {
|
||||||
|
pathSegments.push(noteId);
|
||||||
|
fullNotePaths.push(pathSegments.join("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li class={classes}>
|
||||||
|
{joinElements(fullNotePaths.map(notePath => (
|
||||||
|
<NoteLink notePath={notePath} noPreview />
|
||||||
|
)), " / ")}
|
||||||
|
|
||||||
|
{icons.map(({ icon, title }) => (
|
||||||
|
<span class={icon} title={title} />
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
@ -17,6 +17,7 @@ import NoteInfoTab from "./NoteInfoTab";
|
|||||||
import SimilarNotesTab from "./SimilarNotesTab";
|
import SimilarNotesTab from "./SimilarNotesTab";
|
||||||
import FilePropertiesTab from "./FilePropertiesTab";
|
import FilePropertiesTab from "./FilePropertiesTab";
|
||||||
import ImagePropertiesTab from "./ImagePropertiesTab";
|
import ImagePropertiesTab from "./ImagePropertiesTab";
|
||||||
|
import NotePathsTab from "./NotePathsTab";
|
||||||
|
|
||||||
interface TitleContext {
|
interface TitleContext {
|
||||||
note: FNote | null | undefined;
|
note: FNote | null | undefined;
|
||||||
@ -27,7 +28,7 @@ interface TabConfiguration {
|
|||||||
icon: string;
|
icon: string;
|
||||||
// TODO: Mark as required after porting them all.
|
// TODO: Mark as required after porting them all.
|
||||||
content?: (context: TabContext) => VNode;
|
content?: (context: TabContext) => VNode;
|
||||||
show?: (context: TitleContext) => boolean | null | undefined;
|
show?: boolean | ((context: TitleContext) => boolean | null | undefined);
|
||||||
toggleCommand?: CommandNames;
|
toggleCommand?: CommandNames;
|
||||||
activate?: boolean | ((context: TitleContext) => boolean);
|
activate?: boolean | ((context: TitleContext) => boolean);
|
||||||
/**
|
/**
|
||||||
@ -113,9 +114,11 @@ const TAB_CONFIGURATION = numberObjectsInPlace<TabConfiguration>([
|
|||||||
icon: "bx bx-list-plus"
|
icon: "bx bx-list-plus"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// NotePathsWidget
|
|
||||||
title: t("note_paths.title"),
|
title: t("note_paths.title"),
|
||||||
icon: "bx bx-collection"
|
icon: "bx bx-collection",
|
||||||
|
content: NotePathsTab,
|
||||||
|
show: true,
|
||||||
|
toggleCommand: "toggleRibbonTabNotePaths"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// NoteMapRibbonWidget
|
// NoteMapRibbonWidget
|
||||||
@ -139,10 +142,10 @@ const TAB_CONFIGURATION = numberObjectsInPlace<TabConfiguration>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export default function Ribbon() {
|
export default function Ribbon() {
|
||||||
const { note, ntxId } = useNoteContext();
|
const { note, ntxId, hoistedNoteId, notePath } = useNoteContext();
|
||||||
const titleContext: TitleContext = { note };
|
const titleContext: TitleContext = { note };
|
||||||
const [ activeTabIndex, setActiveTabIndex ] = useState<number | undefined>();
|
const [ activeTabIndex, setActiveTabIndex ] = useState<number | undefined>();
|
||||||
const filteredTabs = useMemo(() => TAB_CONFIGURATION.filter(tab => tab.show?.(titleContext)), [ titleContext, note ]);
|
const filteredTabs = useMemo(() => TAB_CONFIGURATION.filter(tab => typeof tab.show === "boolean" ? tab.show : tab.show?.(titleContext)), [ titleContext, note ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="ribbon-container" style={{ contain: "none" }}>
|
<div class="ribbon-container" style={{ contain: "none" }}>
|
||||||
@ -178,7 +181,9 @@ export default function Ribbon() {
|
|||||||
return tab?.content && tab.content({
|
return tab?.content && tab.content({
|
||||||
note,
|
note,
|
||||||
hidden: !isActive,
|
hidden: !isActive,
|
||||||
ntxId
|
ntxId,
|
||||||
|
hoistedNoteId,
|
||||||
|
notePath
|
||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,4 +4,6 @@ export interface TabContext {
|
|||||||
note: FNote | null | undefined;
|
note: FNote | null | undefined;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
ntxId?: string | null | undefined;
|
ntxId?: string | null | undefined;
|
||||||
|
hoistedNoteId?: string;
|
||||||
|
notePath?: string;
|
||||||
}
|
}
|
||||||
|
@ -222,3 +222,27 @@
|
|||||||
justify-content: space-evenly;
|
justify-content: space-evenly;
|
||||||
}
|
}
|
||||||
/* #endregion */
|
/* #endregion */
|
||||||
|
|
||||||
|
/* #region Note paths */
|
||||||
|
.note-paths-widget {
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-path-list {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-path-list .path-current a {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-path-list .path-archived a {
|
||||||
|
color: var(--muted-text-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-path-list .path-search a {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
/* #endregion */
|
@ -1,153 +0,0 @@
|
|||||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
|
||||||
import treeService from "../../services/tree.js";
|
|
||||||
import linkService from "../../services/link.js";
|
|
||||||
import { t } from "../../services/i18n.js";
|
|
||||||
import type FNote from "../../entities/fnote.js";
|
|
||||||
import type { NotePathRecord } from "../../entities/fnote.js";
|
|
||||||
import type { EventData } from "../../components/app_context.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
|
||||||
<div class="note-paths-widget">
|
|
||||||
<style>
|
|
||||||
.note-paths-widget {
|
|
||||||
padding: 12px;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-path-list {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-path-list .path-current a {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-path-list .path-archived a {
|
|
||||||
color: var(--muted-text-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note-path-list .path-search a {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="note-path-intro"></div>
|
|
||||||
|
|
||||||
<ul class="note-path-list"></ul>
|
|
||||||
|
|
||||||
<button class="btn btn-sm" data-trigger-command="cloneNoteIdsTo">${t("note_paths.clone_button")}</button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
export default class NotePathsWidget extends NoteContextAwareWidget {
|
|
||||||
|
|
||||||
private $notePathIntro!: JQuery<HTMLElement>;
|
|
||||||
private $notePathList!: JQuery<HTMLElement>;
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "notePaths";
|
|
||||||
}
|
|
||||||
|
|
||||||
get toggleCommand() {
|
|
||||||
return "toggleRibbonTabNotePaths";
|
|
||||||
}
|
|
||||||
|
|
||||||
getTitle() {
|
|
||||||
return {
|
|
||||||
show: true,
|
|
||||||
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
doRender() {
|
|
||||||
this.$widget = $(TPL);
|
|
||||||
this.contentSized();
|
|
||||||
|
|
||||||
this.$notePathIntro = this.$widget.find(".note-path-intro");
|
|
||||||
this.$notePathList = this.$widget.find(".note-path-list");
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshWithNote(note: FNote) {
|
|
||||||
this.$notePathList.empty();
|
|
||||||
|
|
||||||
if (!this.note || this.noteId === "root") {
|
|
||||||
this.$notePathList.empty().append(await this.getRenderedPath(["root"]));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedNotePaths = this.note.getSortedNotePathRecords(this.hoistedNoteId).filter((notePath) => !notePath.isHidden);
|
|
||||||
|
|
||||||
if (sortedNotePaths.length > 0) {
|
|
||||||
this.$notePathIntro.text(t("note_paths.intro_placed"));
|
|
||||||
} else {
|
|
||||||
this.$notePathIntro.text(t("note_paths.intro_not_placed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderedPaths: JQuery<HTMLElement>[] = [];
|
|
||||||
|
|
||||||
for (const notePathRecord of sortedNotePaths) {
|
|
||||||
const notePath = notePathRecord.notePath;
|
|
||||||
|
|
||||||
renderedPaths.push(await this.getRenderedPath(notePath, notePathRecord));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$notePathList.empty().append(...renderedPaths);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRenderedPath(notePath: string[], notePathRecord: NotePathRecord | null = null) {
|
|
||||||
const $pathItem = $("<li>");
|
|
||||||
const pathSegments: string[] = [];
|
|
||||||
const lastIndex = notePath.length - 1;
|
|
||||||
|
|
||||||
for (let i = 0; i < notePath.length; i++) {
|
|
||||||
const noteId = notePath[i];
|
|
||||||
pathSegments.push(noteId);
|
|
||||||
const title = await treeService.getNoteTitle(noteId);
|
|
||||||
const $noteLink = await linkService.createLink(pathSegments.join("/"), { title });
|
|
||||||
|
|
||||||
$noteLink.find("a").addClass("no-tooltip-preview tn-link");
|
|
||||||
$pathItem.append($noteLink);
|
|
||||||
|
|
||||||
if (i != lastIndex) {
|
|
||||||
$pathItem.append(" / ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const icons: string[] = [];
|
|
||||||
|
|
||||||
if (this.notePath === notePath.join("/")) {
|
|
||||||
$pathItem.addClass("path-current");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!notePathRecord || notePathRecord.isInHoistedSubTree) {
|
|
||||||
$pathItem.addClass("path-in-hoisted-subtree");
|
|
||||||
} else {
|
|
||||||
icons.push(`<span class="bx bx-trending-up" title="${t("note_paths.outside_hoisted")}"></span>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notePathRecord?.isArchived) {
|
|
||||||
$pathItem.addClass("path-archived");
|
|
||||||
|
|
||||||
icons.push(`<span class="bx bx-archive" title="${t("note_paths.archived")}"></span>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notePathRecord?.isSearch) {
|
|
||||||
$pathItem.addClass("path-search");
|
|
||||||
|
|
||||||
icons.push(`<span class="bx bx-search" title="${t("note_paths.search")}"></span>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (icons.length > 0) {
|
|
||||||
$pathItem.append(` ${icons.join(" ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $pathItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
|
||||||
if (loadResults.getBranchRows().find((branch) => branch.noteId === this.noteId) || (this.noteId != null && loadResults.isNoteReloaded(this.noteId))) {
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user