sketching out layout structure, cleaning Discover
This commit is contained in:
38
src/App.tsx
38
src/App.tsx
@@ -23,6 +23,7 @@ import { useToast } from "@/ui/toast";
|
|||||||
import { useRenderer } from "@opentui/solid";
|
import { useRenderer } from "@opentui/solid";
|
||||||
import type { AuthScreen } from "@/types/auth";
|
import type { AuthScreen } from "@/types/auth";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode } from "@/types/episode";
|
||||||
|
import { DIRECTION } from "./types/navigation";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [activeTab, setActiveTab] = createSignal<TabId>("feed");
|
const [activeTab, setActiveTab] = createSignal<TabId>("feed");
|
||||||
@@ -61,26 +62,23 @@ export function App() {
|
|||||||
onExit: () => setLayerDepth(0),
|
onExit: () => setLayerDepth(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Centralized keyboard handler for all tab navigation and shortcuts
|
|
||||||
useAppKeyboard({
|
useAppKeyboard({
|
||||||
get activeTab() {
|
|
||||||
return activeTab();
|
|
||||||
},
|
|
||||||
onTabChange: (tab: TabId) => {
|
|
||||||
setActiveTab(tab);
|
|
||||||
setInputFocused(false);
|
|
||||||
},
|
|
||||||
get inputFocused() {
|
|
||||||
return inputFocused();
|
|
||||||
},
|
|
||||||
get navigationEnabled() {
|
|
||||||
return layerDepth() === 0;
|
|
||||||
},
|
|
||||||
layerDepth,
|
layerDepth,
|
||||||
onLayerChange: (newDepth) => {
|
onAction: (action, direction) => {
|
||||||
setLayerDepth(newDepth);
|
if (action == "cycle") {
|
||||||
},
|
if (direction == DIRECTION.Increment) {
|
||||||
onAction: (action) => {
|
//if()
|
||||||
|
}
|
||||||
|
if (direction == DIRECTION.Decrement) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action == "depth") {
|
||||||
|
if (direction == DIRECTION.Increment) {
|
||||||
|
}
|
||||||
|
if (direction == DIRECTION.Decrement) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (action === "escape") {
|
if (action === "escape") {
|
||||||
if (layerDepth() > 0) {
|
if (layerDepth() > 0) {
|
||||||
setLayerDepth(0);
|
setLayerDepth(0);
|
||||||
@@ -90,10 +88,6 @@ export function App() {
|
|||||||
setInputFocused(false);
|
setInputFocused(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "enter" && layerDepth() === 0) {
|
|
||||||
setLayerDepth(1);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,145 +3,30 @@
|
|||||||
* Single handler to prevent conflicts
|
* Single handler to prevent conflicts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
import { TabId } from "@/components/TabNavigation";
|
||||||
import type { Accessor } from "solid-js"
|
import { useKeyboard, useRenderer } from "@opentui/solid";
|
||||||
|
import type { Accessor } from "solid-js";
|
||||||
const TAB_ORDER: TabId[] = ["feed", "shows", "discover", "search", "player", "settings"]
|
|
||||||
|
|
||||||
type TabId =
|
|
||||||
| "feed"
|
|
||||||
| "shows"
|
|
||||||
| "discover"
|
|
||||||
| "search"
|
|
||||||
| "player"
|
|
||||||
| "settings"
|
|
||||||
|
|
||||||
type ShortcutOptions = {
|
type ShortcutOptions = {
|
||||||
activeTab: TabId
|
onAction?: (action: string, direction: Direction) => void;
|
||||||
onTabChange: (tab: TabId) => void
|
layerDepth: Accessor<number>;
|
||||||
onAction?: (action: string) => void
|
};
|
||||||
inputFocused?: boolean
|
|
||||||
navigationEnabled?: boolean
|
|
||||||
layerDepth?: Accessor<number>
|
|
||||||
onLayerChange?: (newDepth: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAppKeyboard(options: ShortcutOptions) {
|
export function useAppKeyboard(props: ShortcutOptions) {
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer();
|
||||||
|
|
||||||
const getNextTab = (current: TabId): TabId => {
|
|
||||||
const idx = TAB_ORDER.indexOf(current)
|
|
||||||
return TAB_ORDER[(idx + 1) % TAB_ORDER.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPrevTab = (current: TabId): TabId => {
|
|
||||||
const idx = TAB_ORDER.indexOf(current)
|
|
||||||
return TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// layer depth 0 is tabs, they are oriented
|
||||||
|
// vertically, all others are vertically
|
||||||
useKeyboard((key) => {
|
useKeyboard((key) => {
|
||||||
// Always allow quit
|
// handle cycle current layer
|
||||||
if (key.ctrl && key.name === "q") {
|
if (props.layerDepth() == 0) {
|
||||||
renderer.destroy()
|
let reverse = false;
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "escape") {
|
|
||||||
options.onAction?.("escape")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip global shortcuts if input is focused (let input handle keys)
|
|
||||||
if (options.inputFocused) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.navigationEnabled === false) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return key cycles tabs (equivalent to Tab)
|
|
||||||
if (key.name === "return") {
|
|
||||||
options.onTabChange(getNextTab(options.activeTab))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer navigation with left/right arrows
|
|
||||||
if (options.layerDepth !== undefined && options.onLayerChange) {
|
|
||||||
const currentDepth = options.layerDepth()
|
|
||||||
const maxLayers = 3
|
|
||||||
|
|
||||||
if (key.name === "right") {
|
|
||||||
if (currentDepth < maxLayers) {
|
|
||||||
options.onLayerChange(currentDepth + 1)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "left") {
|
|
||||||
if (currentDepth > 0) {
|
|
||||||
options.onLayerChange(currentDepth - 1)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab navigation with left/right arrows OR [ and ]
|
|
||||||
if (key.name === "right" || key.name === "]") {
|
|
||||||
options.onTabChange(getNextTab(options.activeTab))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "left" || key.name === "[") {
|
|
||||||
options.onTabChange(getPrevTab(options.activeTab))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Number keys for direct tab access (1-6)
|
|
||||||
if (key.name === "1") {
|
|
||||||
options.onTabChange("feed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key.name === "2") {
|
|
||||||
options.onTabChange("shows")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key.name === "3") {
|
|
||||||
options.onTabChange("discover")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key.name === "4") {
|
|
||||||
options.onTabChange("search")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key.name === "5") {
|
|
||||||
options.onTabChange("player")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (key.name === "6") {
|
|
||||||
options.onTabChange("settings")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab key cycles tabs (Shift+Tab goes backwards)
|
|
||||||
if (key.name === "tab") {
|
|
||||||
if (key.shift) {
|
if (key.shift) {
|
||||||
options.onTabChange(getPrevTab(options.activeTab))
|
reverse = true;
|
||||||
} else {
|
|
||||||
options.onTabChange(getNextTab(options.activeTab))
|
|
||||||
}
|
}
|
||||||
return
|
if (key.name == "tab" || key.name == "down" || key.name == "j") {
|
||||||
}
|
|
||||||
|
|
||||||
// Forward other actions
|
|
||||||
if (options.onAction) {
|
|
||||||
if (key.ctrl && key.name === "s") {
|
|
||||||
options.onAction("save")
|
|
||||||
} else if (key.ctrl && key.name === "f") {
|
|
||||||
options.onAction("find")
|
|
||||||
} else if (key.name === "?" || (key.shift && key.name === "/")) {
|
|
||||||
options.onAction("help")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
// handle cycle depth
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
/**
|
|
||||||
* CategoryFilter component - Horizontal category filter tabs
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { For } from "solid-js";
|
|
||||||
import type { DiscoverCategory } from "@/stores/discover";
|
|
||||||
|
|
||||||
type CategoryFilterProps = {
|
|
||||||
categories: DiscoverCategory[];
|
|
||||||
selectedCategory: string;
|
|
||||||
focused: boolean;
|
|
||||||
onSelect?: (categoryId: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CategoryFilter(props: CategoryFilterProps) {
|
|
||||||
return (
|
|
||||||
<box flexDirection="row" gap={1} flexWrap="wrap">
|
|
||||||
<For each={props.categories}>
|
|
||||||
{(category) => {
|
|
||||||
const isSelected = () => props.selectedCategory === category.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
border={isSelected()}
|
|
||||||
backgroundColor={isSelected() ? "#444" : undefined}
|
|
||||||
onMouseDown={() => props.onSelect?.(category.id)}
|
|
||||||
>
|
|
||||||
<text fg={isSelected() ? "cyan" : "gray"}>
|
|
||||||
{category.icon} {category.name}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
* DiscoverPage component - Main discover/browse interface for PodTUI
|
* DiscoverPage component - Main discover/browse interface for PodTUI
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal, For, Show } from "solid-js";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
import { useKeyboard } from "@opentui/solid";
|
||||||
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
||||||
import { CategoryFilter } from "./CategoryFilter";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { TrendingShows } from "./TrendingShows";
|
import { PodcastCard } from "./PodcastCard";
|
||||||
|
|
||||||
type DiscoverPageProps = {
|
type DiscoverPageProps = {
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
@@ -37,10 +37,7 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (key.name === "return" && area === "categories") {
|
||||||
key.name === "return" &&
|
|
||||||
area === "categories"
|
|
||||||
) {
|
|
||||||
setFocusArea("shows");
|
setFocusArea("shows");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -141,42 +138,47 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
|||||||
discoverStore.toggleSubscription(podcast.id);
|
discoverStore.toggleSubscription(podcast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box flexDirection="column" height="100%" gap={1}>
|
<box flexDirection="row" flexGrow={1} height="100%" gap={1}>
|
||||||
{/* Header */}
|
|
||||||
<box
|
<box
|
||||||
flexDirection="row"
|
border
|
||||||
justifyContent="space-between"
|
padding={1}
|
||||||
alignItems="center"
|
borderColor={theme.border}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={1}
|
||||||
|
width={20}
|
||||||
>
|
>
|
||||||
<text>
|
<text fg={focusArea() === "categories" ? theme.accent : theme.text}>
|
||||||
<strong>Discover Podcasts</strong>
|
|
||||||
</text>
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<text fg="gray">{discoverStore.filteredPodcasts().length} shows</text>
|
|
||||||
<box onMouseDown={() => discoverStore.refresh()}>
|
|
||||||
<text fg="cyan">[R] Refresh</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Category Filter */}
|
|
||||||
<box border padding={1}>
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<text fg={focusArea() === "categories" ? "cyan" : "gray"}>
|
|
||||||
Categories:
|
Categories:
|
||||||
</text>
|
</text>
|
||||||
<CategoryFilter
|
<box flexDirection="column" gap={1}>
|
||||||
categories={discoverStore.categories}
|
<For each={discoverStore.categories}>
|
||||||
selectedCategory={discoverStore.selectedCategory()}
|
{(category) => {
|
||||||
focused={focusArea() === "categories"}
|
const isSelected = () =>
|
||||||
onSelect={handleCategorySelect}
|
discoverStore.selectedCategory() === category.id;
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Trending Shows */}
|
return (
|
||||||
<box flexDirection="column" flexGrow={1} border>
|
<box
|
||||||
|
border={isSelected()}
|
||||||
|
backgroundColor={isSelected() ? theme.accent : undefined}
|
||||||
|
onMouseDown={() => handleCategorySelect(category.id)}
|
||||||
|
>
|
||||||
|
<text fg={isSelected() ? "cyan" : "gray"}>
|
||||||
|
{category.icon} {category.name}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
flexGrow={1}
|
||||||
|
border
|
||||||
|
borderColor={theme.border}
|
||||||
|
>
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
|
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
|
||||||
Trending in{" "}
|
Trending in{" "}
|
||||||
@@ -185,23 +187,40 @@ export function DiscoverPage(props: DiscoverPageProps) {
|
|||||||
)?.name ?? "All"}
|
)?.name ?? "All"}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<TrendingShows
|
<box flexDirection="column" height="100%">
|
||||||
podcasts={discoverStore.filteredPodcasts()}
|
<Show
|
||||||
selectedIndex={showIndex()}
|
fallback={
|
||||||
focused={focusArea() === "shows"}
|
<box padding={2}>
|
||||||
isLoading={discoverStore.isLoading()}
|
{discoverStore.filteredPodcasts().length !== 0 ? (
|
||||||
onSelect={handleShowSelect}
|
<text fg="yellow">Loading trending shows...</text>
|
||||||
onSubscribe={handleSubscribe}
|
) : (
|
||||||
/>
|
<text fg="gray">No podcasts found in this category.</text>
|
||||||
|
)}
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
when={
|
||||||
|
!discoverStore.isLoading() &&
|
||||||
|
discoverStore.filteredPodcasts().length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<scrollbox>
|
||||||
|
<box flexDirection="column">
|
||||||
|
<For each={discoverStore.filteredPodcasts()}>
|
||||||
|
{(podcast, index) => (
|
||||||
|
<PodcastCard
|
||||||
|
podcast={podcast}
|
||||||
|
selected={
|
||||||
|
index() === showIndex() && focusArea() === "shows"
|
||||||
|
}
|
||||||
|
onSelect={() => handleShowSelect(index())}
|
||||||
|
onSubscribe={() => handleSubscribe(podcast)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</scrollbox>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
{/* Footer Hints */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<text fg="gray">[Tab] Switch focus</text>
|
|
||||||
<text fg="gray">[j/k] Navigate</text>
|
|
||||||
<text fg="gray">[Enter] Subscribe</text>
|
|
||||||
<text fg="gray">[Esc] Up</text>
|
|
||||||
<text fg="gray">[R] Refresh</text>
|
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
/**
|
|
||||||
* TrendingShows component - Grid/list of trending podcasts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { For, Show } from "solid-js";
|
|
||||||
import type { Podcast } from "@/types/podcast";
|
|
||||||
import { PodcastCard } from "./PodcastCard";
|
|
||||||
|
|
||||||
type TrendingShowsProps = {
|
|
||||||
podcasts: Podcast[];
|
|
||||||
selectedIndex: number;
|
|
||||||
focused: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
onSelect?: (index: number) => void;
|
|
||||||
onSubscribe?: (podcast: Podcast) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TrendingShows(props: TrendingShowsProps) {
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" height="100%">
|
|
||||||
<Show when={props.isLoading}>
|
|
||||||
<box padding={2}>
|
|
||||||
<text fg="yellow">Loading trending shows...</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!props.isLoading && props.podcasts.length === 0}>
|
|
||||||
<box padding={2}>
|
|
||||||
<text fg="gray">No podcasts found in this category.</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!props.isLoading && props.podcasts.length > 0}>
|
|
||||||
<scrollbox height={15}>
|
|
||||||
<box flexDirection="column">
|
|
||||||
<For each={props.podcasts}>
|
|
||||||
{(podcast, index) => (
|
|
||||||
<PodcastCard
|
|
||||||
podcast={podcast}
|
|
||||||
selected={index() === props.selectedIndex && props.focused}
|
|
||||||
onSelect={() => props.onSelect?.(index())}
|
|
||||||
onSubscribe={() => props.onSubscribe?.(podcast)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
4
src/types/navigation.ts
Normal file
4
src/types/navigation.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum DIRECTION {
|
||||||
|
Increment,
|
||||||
|
Decrement,
|
||||||
|
}
|
||||||
64
src/utils/navigation.ts
Normal file
64
src/utils/navigation.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
enum FEEDTABTYPE {
|
||||||
|
LATEST,
|
||||||
|
}
|
||||||
|
export const FeedTab = {
|
||||||
|
[FEEDTABTYPE.LATEST]: {
|
||||||
|
size: 1,
|
||||||
|
title: "Feed - Latest Episodes",
|
||||||
|
scrolling: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
enum MYSHOWSTYPE {
|
||||||
|
SHOWLIST,
|
||||||
|
EPISODELIST,
|
||||||
|
}
|
||||||
|
export const MyShowsTab = {
|
||||||
|
[MYSHOWSTYPE.SHOWLIST]: { size: 0.3, title: "My Shows", scrolling: true },
|
||||||
|
[MYSHOWSTYPE.EPISODELIST]: {
|
||||||
|
size: 0.7,
|
||||||
|
title: "<SHOW> - Episodes",
|
||||||
|
scrolling: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
enum DiscoverTab {
|
||||||
|
CATEGORIES,
|
||||||
|
CATEGORYLIST,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CATEGORIES {
|
||||||
|
ALL,
|
||||||
|
TECHNOLOGY,
|
||||||
|
SCIENCE,
|
||||||
|
COMEDY,
|
||||||
|
NEWS,
|
||||||
|
BUSINESS,
|
||||||
|
HEALTH,
|
||||||
|
EDUCATION,
|
||||||
|
SPORTS,
|
||||||
|
TRUECRIME,
|
||||||
|
ARTS,
|
||||||
|
}
|
||||||
|
export const SearchTab = [];
|
||||||
|
|
||||||
|
export const PlayerTab = [];
|
||||||
|
|
||||||
|
export const SettingsTab = [];
|
||||||
|
|
||||||
|
export enum TABS {
|
||||||
|
FEED,
|
||||||
|
MYSHOWS,
|
||||||
|
DISCOVER,
|
||||||
|
SEARCH,
|
||||||
|
PLAYER,
|
||||||
|
SETTINGS,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LayerGraph = {
|
||||||
|
[TABS.FEED]: FeedTab,
|
||||||
|
[TABS.MYSHOWS]: MyShowsTab,
|
||||||
|
[TABS.DISCOVER]: DiscoverTab,
|
||||||
|
[TABS.SEARCH]: SearchTab,
|
||||||
|
[TABS.PLAYER]: PlayerTab,
|
||||||
|
[TABS.SETTINGS]: SettingsTab,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user