feat: ability to pin custom pages to the help menu

This commit is contained in:
naskya 2023-11-26 20:14:02 +00:00 committed by Kainoa Kanter
parent 3272b908c6
commit e7e6ae69e0
11 changed files with 138 additions and 11 deletions

View file

@ -4,6 +4,11 @@ Breaking changes are indicated by the :warning: icon.
## v1.0.5 (unreleased) ## v1.0.5 (unreleased)
### dev21
- `admin/update-meta` can now take `moreUrls` parameter, and response of `admin/meta` now includes `moreUrls`
- These URLs are used for the help menu ([related merge request](https://git.joinfirefish.org/firefish/firefish/-/merge_requests/10640))
### dev18 ### dev18
- :warning: response of `meta` no longer includes the following: - :warning: response of `meta` no longer includes the following:

View file

@ -2161,3 +2161,5 @@ _iconSets:
regular: "Regular" regular: "Regular"
fill: "Filled" fill: "Filled"
duotone: "Duotone" duotone: "Duotone"
moreUrls: "Pinned pages"
moreUrlsDescription: "Enter the pages you want to pin to the help menu in the lower left corner using this notation:\n\"Display name\": https://example.com/"

View file

@ -2003,3 +2003,5 @@ _iconSets:
regular: "標準" regular: "標準"
fill: "塗りつぶし" fill: "塗りつぶし"
duotone: "2色" duotone: "2色"
moreUrls: "固定するページ"
moreUrlsDescription: "左下のヘルプメニューに固定したいページを以下の形式で、改行区切りで入力してください:\n\"表示名\": https://example.com/"

View file

@ -0,0 +1,13 @@
export class MoreUrls1699305365258 {
name = "MoreUrls1699305365258";
async up(queryRunner) {
queryRunner.query(
`ALTER TABLE "meta" ADD "moreUrls" jsonb NOT NULL DEFAULT '[]'`,
);
}
async down(queryRunner) {
queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "moreUrls"`);
}
}

View file

@ -75,6 +75,8 @@ pub struct Model {
pub pinned_users: StringVec, pub pinned_users: StringVec,
#[sea_orm(column_name = "ToSUrl")] #[sea_orm(column_name = "ToSUrl")]
pub to_s_url: Option<String>, pub to_s_url: Option<String>,
#[sea_orm(column_name = "moreUrls", column_type = "JsonBinary")]
pub more_urls: Json,
#[sea_orm(column_name = "repositoryUrl")] #[sea_orm(column_name = "repositoryUrl")]
pub repository_url: String, pub repository_url: String,
#[sea_orm(column_name = "feedbackUrl")] #[sea_orm(column_name = "feedbackUrl")]

View file

@ -383,6 +383,12 @@ export class Meta {
}) })
public ToSUrl: string | null; public ToSUrl: string | null;
@Column("jsonb", {
default: [],
nullable: false,
})
public moreUrls: [string, string][];
@Column("varchar", { @Column("varchar", {
length: 512, length: 512,
default: "https://git.joinfirefish.org/firefish/firefish", default: "https://git.joinfirefish.org/firefish/firefish",

View file

@ -472,6 +472,7 @@ export default define(meta, paramDef, async (ps, me) => {
description: instance.description, description: instance.description,
langs: instance.langs, langs: instance.langs,
tosUrl: instance.ToSUrl, tosUrl: instance.ToSUrl,
moreUrls: instance.moreUrls,
repositoryUrl: instance.repositoryUrl, repositoryUrl: instance.repositoryUrl,
feedbackUrl: instance.feedbackUrl, feedbackUrl: instance.feedbackUrl,
disableRegistration: instance.disableRegistration, disableRegistration: instance.disableRegistration,

View file

@ -143,6 +143,17 @@ export const paramDef = {
swPublicKey: { type: "string", nullable: true }, swPublicKey: { type: "string", nullable: true },
swPrivateKey: { type: "string", nullable: true }, swPrivateKey: { type: "string", nullable: true },
tosUrl: { type: "string", nullable: true }, tosUrl: { type: "string", nullable: true },
moreUrls: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
url: { type: "string" },
},
},
nullable: true,
},
repositoryUrl: { type: "string" }, repositoryUrl: { type: "string" },
feedbackUrl: { type: "string" }, feedbackUrl: { type: "string" },
useObjectStorage: { type: "boolean" }, useObjectStorage: { type: "boolean" },
@ -174,6 +185,18 @@ export const paramDef = {
required: [], required: [],
} as const; } as const;
function isValidHttpUrl(src: string) {
let url;
try {
url = new URL(src);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
}
export default define(meta, paramDef, async (ps, me) => { export default define(meta, paramDef, async (ps, me) => {
const set = {} as Partial<Meta>; const set = {} as Partial<Meta>;
@ -434,6 +457,14 @@ export default define(meta, paramDef, async (ps, me) => {
set.ToSUrl = ps.tosUrl; set.ToSUrl = ps.tosUrl;
} }
if (ps.moreUrls !== undefined) {
const areUrlsVaild = ps.moreUrls.every(
(obj: { name: string; url: string }) => isValidHttpUrl(String(obj.url)),
);
if (!areUrlsVaild) throw new Error("invalid URL");
set.moreUrls = ps.moreUrls;
}
if (ps.repositoryUrl !== undefined) { if (ps.repositoryUrl !== undefined) {
set.repositoryUrl = ps.repositoryUrl; set.repositoryUrl = ps.repositoryUrl;
} }

View file

@ -64,6 +64,11 @@ export const meta = {
optional: false, optional: false,
nullable: true, nullable: true,
}, },
moreUrls: {
type: "object",
optional: false,
nullable: false,
},
repositoryUrl: { repositoryUrl: {
type: "string", type: "string",
optional: false, optional: false,
@ -416,6 +421,7 @@ export default define(meta, paramDef, async (ps, me) => {
description: instance.description, description: instance.description,
langs: instance.langs, langs: instance.langs,
tosUrl: instance.ToSUrl, tosUrl: instance.ToSUrl,
moreUrls: instance.moreUrls,
repositoryUrl: instance.repositoryUrl, repositoryUrl: instance.repositoryUrl,
feedbackUrl: instance.feedbackUrl, feedbackUrl: instance.feedbackUrl,

View file

@ -24,11 +24,18 @@
<FormInput v-model="tosUrl" class="_formBlock"> <FormInput v-model="tosUrl" class="_formBlock">
<template #prefix <template #prefix
><i :class="icon('ph-link-simple')"></i ><i :class="icon('ph-scroll')"></i
></template> ></template>
<template #label>{{ i18n.ts.tosUrl }}</template> <template #label>{{ i18n.ts.tosUrl }}</template>
</FormInput> </FormInput>
<FormTextarea v-model="moreUrls" class="_formBlock">
<template #label>{{ i18n.ts.moreUrls }}</template>
<template #caption>{{
i18n.ts.moreUrlsDescription
}}</template>
</FormTextarea>
<FormSplit :min-width="300"> <FormSplit :min-width="300">
<FormInput <FormInput
v-model="maintainerName" v-model="maintainerName"
@ -446,6 +453,7 @@ import icon from "@/scripts/icon";
const name = ref<string | null>(null); const name = ref<string | null>(null);
const description = ref<string | null>(null); const description = ref<string | null>(null);
const tosUrl = ref<string | null>(null); const tosUrl = ref<string | null>(null);
const moreUrls = ref<string | null>(null);
const maintainerName = ref<string | null>(null); const maintainerName = ref<string | null>(null);
const maintainerEmail = ref<string | null>(null); const maintainerEmail = ref<string | null>(null);
const donationLink = ref<string | null>(null); const donationLink = ref<string | null>(null);
@ -480,12 +488,44 @@ const defaultReactionCustom = ref("");
const enableServerMachineStats = ref(false); const enableServerMachineStats = ref(false);
const enableIdenticonGeneration = ref(false); const enableIdenticonGeneration = ref(false);
function isValidHttpUrl(src: string) {
let url: URL;
try {
url = new URL(src);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
}
function parseMoreUrls(src: string): { name: string; url: string }[] {
const toReturn: { name: string; url: string }[] = [];
const pattern = /"(.+)"\s*:\s*(http.+)/;
src.trim()
.split("\n")
.forEach((line) => {
const match = pattern.exec(line);
if (match != null && isValidHttpUrl(match[2]))
toReturn.push({ name: match[1], url: match[2] });
else console.error(`invalid syntax or invalid URL: ${line}`);
});
return toReturn;
}
function stringifyMoreUrls(src: { name: string; url: string }[]): string {
let toReturn = "";
for (const { name, url } of src)
toReturn = toReturn.concat(`"${name}": ${url}`, "\n");
return toReturn;
}
async function init() { async function init() {
const meta = await os.api("admin/meta"); const meta = await os.api("admin/meta");
if (!meta) throw new Error("No meta"); if (!meta) throw new Error("No meta");
name.value = meta.name; name.value = meta.name;
description.value = meta.description; description.value = meta.description;
tosUrl.value = meta.tosUrl; tosUrl.value = meta.tosUrl;
moreUrls.value = stringifyMoreUrls(meta.moreUrls);
iconUrl.value = meta.iconUrl; iconUrl.value = meta.iconUrl;
bannerUrl.value = meta.bannerUrl; bannerUrl.value = meta.bannerUrl;
logoImageUrl.value = meta.logoImageUrl; logoImageUrl.value = meta.logoImageUrl;
@ -535,6 +575,7 @@ function save() {
name: name.value, name: name.value,
description: description.value, description: description.value,
tosUrl: tosUrl.value, tosUrl: tosUrl.value,
moreUrls: parseMoreUrls(moreUrls.value ?? ""),
iconUrl: iconUrl.value, iconUrl: iconUrl.value,
bannerUrl: bannerUrl.value, bannerUrl: bannerUrl.value,
logoImageUrl: logoImageUrl.value, logoImageUrl: logoImageUrl.value,

View file

@ -5,6 +5,31 @@ import { host } from "@/config";
import * as os from "@/os"; import * as os from "@/os";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import icon from "@/scripts/icon"; import icon from "@/scripts/icon";
import type { MenuItem } from "@/types/menu";
const instanceSpecificItems: MenuItem[] = [];
if (instance.tosUrl != null) {
instanceSpecificItems.push({
type: "button",
text: i18n.ts.tos,
icon: `${icon("ph-scroll")}`,
action: () => {
window.open(instance.tosUrl, "_blank");
},
});
}
for (const { name, url } of instance.moreUrls) {
instanceSpecificItems.push({
type: "button",
text: name,
icon: `${icon("ph-link-simple")}`,
action: () => {
window.open(url, "_blank");
},
});
}
export function openHelpMenu_(ev: MouseEvent) { export function openHelpMenu_(ev: MouseEvent) {
os.popupMenu( os.popupMenu(
@ -25,16 +50,9 @@ export function openHelpMenu_(ev: MouseEvent) {
icon: `${icon("ph-lightbulb")}`, icon: `${icon("ph-lightbulb")}`,
to: "/about-firefish", to: "/about-firefish",
}, },
instance.tosUrl ...(instanceSpecificItems.length >= 2 ? [null] : []),
? { ...instanceSpecificItems,
type: "button", null,
text: i18n.ts.tos,
icon: `${icon("ph-scroll")}`,
action: () => {
window.open(instance.tosUrl, "_blank");
},
}
: null,
{ {
type: "button", type: "button",
text: i18n.ts.apps, text: i18n.ts.apps,