mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: add overseerr version and update availability status to sidebar
sort of experimental so may be kinda broken. :)
This commit is contained in:
133
server/api/github.ts
Normal file
133
server/api/github.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import cacheManager from '../lib/cache';
|
||||||
|
import logger from '../logger';
|
||||||
|
import ExternalAPI from './externalapi';
|
||||||
|
|
||||||
|
interface GitHubRelease {
|
||||||
|
url: string;
|
||||||
|
assets_url: string;
|
||||||
|
upload_url: string;
|
||||||
|
html_url: string;
|
||||||
|
id: number;
|
||||||
|
node_id: string;
|
||||||
|
tag_name: string;
|
||||||
|
target_commitish: string;
|
||||||
|
name: string;
|
||||||
|
draft: boolean;
|
||||||
|
prerelease: boolean;
|
||||||
|
created_at: string;
|
||||||
|
published_at: string;
|
||||||
|
tarball_url: string;
|
||||||
|
zipball_url: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GithubCommit {
|
||||||
|
sha: string;
|
||||||
|
node_id: string;
|
||||||
|
commit: {
|
||||||
|
author: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
committer: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
tree: {
|
||||||
|
sha: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
url: string;
|
||||||
|
comment_count: number;
|
||||||
|
verification: {
|
||||||
|
verified: boolean;
|
||||||
|
reason: string;
|
||||||
|
signature: string;
|
||||||
|
payload: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
url: string;
|
||||||
|
html_url: string;
|
||||||
|
comments_url: string;
|
||||||
|
parents: [
|
||||||
|
{
|
||||||
|
sha: string;
|
||||||
|
url: string;
|
||||||
|
html_url: string;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class GithubAPI extends ExternalAPI {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
'https://api.github.com',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
nodeCache: cacheManager.getCache('github').data,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getOverseerrReleases({
|
||||||
|
take = 20,
|
||||||
|
}: {
|
||||||
|
take?: number;
|
||||||
|
} = {}): Promise<GitHubRelease[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<GitHubRelease[]>(
|
||||||
|
'/repos/sct/overseerr/releases',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
per_page: take,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(
|
||||||
|
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
|
||||||
|
{ label: 'GitHub API', errorMessage: e.message }
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getOverseerrCommits({
|
||||||
|
take = 20,
|
||||||
|
branch = 'develop',
|
||||||
|
}: {
|
||||||
|
take?: number;
|
||||||
|
branch?: string;
|
||||||
|
} = {}): Promise<GithubCommit[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.get<GithubCommit[]>(
|
||||||
|
'/repos/sct/overseerr/commits',
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
per_page: take,
|
||||||
|
branch,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(
|
||||||
|
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
|
||||||
|
{ label: 'GitHub API', errorMessage: e.message }
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GithubAPI;
|
@@ -43,3 +43,10 @@ export interface CacheItem {
|
|||||||
vsize: number;
|
vsize: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatusResponse {
|
||||||
|
version: string;
|
||||||
|
commitTag: string;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
commitsBehind: number;
|
||||||
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import NodeCache from 'node-cache';
|
import NodeCache from 'node-cache';
|
||||||
|
|
||||||
export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt';
|
export type AvailableCacheIds = 'tmdb' | 'radarr' | 'sonarr' | 'rt' | 'github';
|
||||||
|
|
||||||
const DEFAULT_TTL = 300;
|
const DEFAULT_TTL = 300;
|
||||||
const DEFAULT_CHECK_PERIOD = 120;
|
const DEFAULT_CHECK_PERIOD = 120;
|
||||||
@@ -44,6 +44,10 @@ class CacheManager {
|
|||||||
stdTtl: 43200,
|
stdTtl: 43200,
|
||||||
checkPeriod: 60 * 30,
|
checkPeriod: 60 * 30,
|
||||||
}),
|
}),
|
||||||
|
github: new Cache('github', 'GitHub API', {
|
||||||
|
stdTtl: 21600,
|
||||||
|
checkPeriod: 60 * 30,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
public getCache(id: AvailableCacheIds): Cache {
|
public getCache(id: AvailableCacheIds): Cache {
|
||||||
|
@@ -1,33 +1,75 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import user from './user';
|
import GithubAPI from '../api/github';
|
||||||
import authRoutes from './auth';
|
import TheMovieDb from '../api/themoviedb';
|
||||||
import { checkUser, isAuthenticated } from '../middleware/auth';
|
import { StatusResponse } from '../interfaces/api/settingsInterfaces';
|
||||||
import settingsRoutes from './settings';
|
|
||||||
import { Permission } from '../lib/permissions';
|
import { Permission } from '../lib/permissions';
|
||||||
import { getSettings } from '../lib/settings';
|
import { getSettings } from '../lib/settings';
|
||||||
import searchRoutes from './search';
|
import { checkUser, isAuthenticated } from '../middleware/auth';
|
||||||
import discoverRoutes from './discover';
|
|
||||||
import requestRoutes from './request';
|
|
||||||
import movieRoutes from './movie';
|
|
||||||
import tvRoutes from './tv';
|
|
||||||
import mediaRoutes from './media';
|
|
||||||
import personRoutes from './person';
|
|
||||||
import collectionRoutes from './collection';
|
|
||||||
import { getAppVersion, getCommitTag } from '../utils/appVersion';
|
|
||||||
import serviceRoutes from './service';
|
|
||||||
import { appDataStatus, appDataPath } from '../utils/appDataVolume';
|
|
||||||
import TheMovieDb from '../api/themoviedb';
|
|
||||||
import { mapProductionCompany } from '../models/Movie';
|
import { mapProductionCompany } from '../models/Movie';
|
||||||
import { mapNetwork } from '../models/Tv';
|
import { mapNetwork } from '../models/Tv';
|
||||||
|
import { appDataPath, appDataStatus } from '../utils/appDataVolume';
|
||||||
|
import { getAppVersion, getCommitTag } from '../utils/appVersion';
|
||||||
|
import authRoutes from './auth';
|
||||||
|
import collectionRoutes from './collection';
|
||||||
|
import discoverRoutes from './discover';
|
||||||
|
import mediaRoutes from './media';
|
||||||
|
import movieRoutes from './movie';
|
||||||
|
import personRoutes from './person';
|
||||||
|
import requestRoutes from './request';
|
||||||
|
import searchRoutes from './search';
|
||||||
|
import serviceRoutes from './service';
|
||||||
|
import settingsRoutes from './settings';
|
||||||
|
import tvRoutes from './tv';
|
||||||
|
import user from './user';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(checkUser);
|
router.use(checkUser);
|
||||||
|
|
||||||
router.get('/status', (req, res) => {
|
router.get<unknown, StatusResponse>('/status', async (req, res) => {
|
||||||
|
const githubApi = new GithubAPI();
|
||||||
|
|
||||||
|
const currentVersion = getAppVersion();
|
||||||
|
const commitTag = getCommitTag();
|
||||||
|
let updateAvailable = false;
|
||||||
|
let commitsBehind = 0;
|
||||||
|
|
||||||
|
if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
|
||||||
|
const commits = await githubApi.getOverseerrCommits();
|
||||||
|
|
||||||
|
if (commits.length) {
|
||||||
|
const filteredCommits = commits.filter(
|
||||||
|
(commit) => !commit.commit.message.includes('[skip ci]')
|
||||||
|
);
|
||||||
|
if (filteredCommits[0].sha !== commitTag) {
|
||||||
|
updateAvailable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitIndex = filteredCommits.findIndex(
|
||||||
|
(commit) => commit.sha === commitTag
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateAvailable) {
|
||||||
|
commitsBehind = commitIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (commitTag !== 'local') {
|
||||||
|
const releases = await githubApi.getOverseerrReleases();
|
||||||
|
|
||||||
|
if (releases.length) {
|
||||||
|
const latestVersion = releases[0];
|
||||||
|
|
||||||
|
if (latestVersion.name !== currentVersion) {
|
||||||
|
updateAvailable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
version: getAppVersion(),
|
version: getAppVersion(),
|
||||||
commitTag: getCommitTag(),
|
commitTag: getCommitTag(),
|
||||||
|
updateAvailable,
|
||||||
|
commitsBehind,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +81,7 @@ router.get('/status/appdata', (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.use('/user', isAuthenticated(), user);
|
router.use('/user', isAuthenticated(), user);
|
||||||
router.get('/settings/public', (_req, res) => {
|
router.get('/settings/public', async (_req, res) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
return res.status(200).json(settings.fullPublicSettings);
|
return res.status(200).json(settings.fullPublicSettings);
|
||||||
|
@@ -5,6 +5,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
|||||||
import useClickOutside from '../../../hooks/useClickOutside';
|
import useClickOutside from '../../../hooks/useClickOutside';
|
||||||
import { Permission, useUser } from '../../../hooks/useUser';
|
import { Permission, useUser } from '../../../hooks/useUser';
|
||||||
import Transition from '../../Transition';
|
import Transition from '../../Transition';
|
||||||
|
import VersionStatus from '../VersionStatus';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
dashboard: 'Discover',
|
dashboard: 'Discover',
|
||||||
@@ -122,6 +123,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
useClickOutside(navRef, () => setClosed());
|
useClickOutside(navRef, () => setClosed());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
@@ -172,7 +174,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={navRef}
|
ref={navRef}
|
||||||
className="flex-1 h-0 pt-5 pb-4 overflow-y-auto"
|
className="flex flex-col flex-1 h-0 pt-5 pb-4 overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div className="flex items-center flex-shrink-0 px-4">
|
<div className="flex items-center flex-shrink-0 px-4">
|
||||||
<span className="text-xl text-gray-50">
|
<span className="text-xl text-gray-50">
|
||||||
@@ -181,7 +183,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="px-2 mt-5 space-y-1">
|
<nav className="flex-1 px-2 mt-5 space-y-1">
|
||||||
{SidebarLinks.filter((link) =>
|
{SidebarLinks.filter((link) =>
|
||||||
link.requiredPermission
|
link.requiredPermission
|
||||||
? hasPermission(link.requiredPermission)
|
? hasPermission(link.requiredPermission)
|
||||||
@@ -221,6 +223,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
{hasPermission(Permission.ADMIN) && <VersionStatus />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 w-14">
|
<div className="flex-shrink-0 w-14">
|
||||||
@@ -273,6 +276,7 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
{hasPermission(Permission.ADMIN) && <VersionStatus />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
122
src/components/Layout/VersionStatus/index.tsx
Normal file
122
src/components/Layout/VersionStatus/index.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { StatusResponse } from '../../../../server/interfaces/api/settingsInterfaces';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
streamdevelop: 'Overseerr Develop',
|
||||||
|
streamstable: 'Overseerr Stable',
|
||||||
|
outofdate: 'Out of date',
|
||||||
|
commitsbehind:
|
||||||
|
'{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind',
|
||||||
|
});
|
||||||
|
|
||||||
|
const VersionStatus: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { data } = useSWR<StatusResponse>('/api/v1/status', {
|
||||||
|
refreshInterval: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionStream =
|
||||||
|
data.commitTag === 'local'
|
||||||
|
? 'Keep it up!'
|
||||||
|
: data.version.startsWith('develop-')
|
||||||
|
? intl.formatMessage(messages.streamdevelop)
|
||||||
|
: intl.formatMessage(messages.streamstable);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href="/settings/about">
|
||||||
|
<a
|
||||||
|
className={`flex items-center p-2 mx-2 text-xs transition duration-300 rounded-lg ring-1 ring-gray-700 ${
|
||||||
|
data.updateAvailable
|
||||||
|
? 'bg-green-500 text-white hover:bg-green-400'
|
||||||
|
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.commitTag === 'local' ? (
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : data.version.startsWith('develop-') ? (
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col flex-1 min-w-0 px-2 truncate last:pr-0">
|
||||||
|
<span className="font-bold">{versionStream}</span>
|
||||||
|
<span className="truncate">
|
||||||
|
{data.commitTag === 'local'
|
||||||
|
? '(⌐■_■)'
|
||||||
|
: data.commitsBehind > 0
|
||||||
|
? intl.formatMessage(messages.commitsbehind, {
|
||||||
|
commitsBehind: data.commitsBehind,
|
||||||
|
})
|
||||||
|
: data.commitsBehind === -1
|
||||||
|
? intl.formatMessage(messages.outofdate)
|
||||||
|
: data.version.replace('develop-', '')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{data.updateAvailable && (
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 11l3-3m0 0l3 3m-3-3v8m0-13a9 9 0 110 18 9 9 0 010-18z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VersionStatus;
|
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
import { StatusResponse } from '../../../server/interfaces/api/settingsInterfaces';
|
||||||
import Modal from '../Common/Modal';
|
import Modal from '../Common/Modal';
|
||||||
import Transition from '../Transition';
|
import Transition from '../Transition';
|
||||||
|
|
||||||
@@ -13,12 +14,9 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
const StatusChecker: React.FC = () => {
|
const StatusChecker: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { data, error } = useSWR<{ version: string; commitTag: string }>(
|
const { data, error } = useSWR<StatusResponse>('/api/v1/status', {
|
||||||
'/api/v1/status',
|
refreshInterval: 60 * 1000,
|
||||||
{
|
});
|
||||||
refreshInterval: 60 * 1000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!data && !error) {
|
if (!data && !error) {
|
||||||
return null;
|
return null;
|
||||||
|
@@ -43,6 +43,10 @@
|
|||||||
"components.Layout.UserDropdown.myprofile": "Profile",
|
"components.Layout.UserDropdown.myprofile": "Profile",
|
||||||
"components.Layout.UserDropdown.settings": "Settings",
|
"components.Layout.UserDropdown.settings": "Settings",
|
||||||
"components.Layout.UserDropdown.signout": "Sign Out",
|
"components.Layout.UserDropdown.signout": "Sign Out",
|
||||||
|
"components.Layout.VersionStatus.commitsbehind": "{commitsBehind} {commitsBehind, plural, one {commit} other {commits}} behind",
|
||||||
|
"components.Layout.VersionStatus.outofdate": "Out of date",
|
||||||
|
"components.Layout.VersionStatus.streamdevelop": "Overseerr Develop",
|
||||||
|
"components.Layout.VersionStatus.streamstable": "Overseerr Stable",
|
||||||
"components.Layout.alphawarning": "This is ALPHA software. Features may be broken and/or unstable. Please report any issues on GitHub!",
|
"components.Layout.alphawarning": "This is ALPHA software. Features may be broken and/or unstable. Please report any issues on GitHub!",
|
||||||
"components.Login.email": "Email Address",
|
"components.Login.email": "Email Address",
|
||||||
"components.Login.forgotpassword": "Forgot Password?",
|
"components.Login.forgotpassword": "Forgot Password?",
|
||||||
|
Reference in New Issue
Block a user