mirror of
https://github.com/coaidev/coai.git
synced 2025-05-20 05:20:15 +09:00
feat: support custom pwa manifest
This commit is contained in:
parent
5366fb7307
commit
3335e81c1e
@ -14,6 +14,7 @@ export type GeneralState = {
|
|||||||
backend: string;
|
backend: string;
|
||||||
docs: string;
|
docs: string;
|
||||||
file: string;
|
file: string;
|
||||||
|
pwa_manifest: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailState = {
|
export type MailState = {
|
||||||
@ -117,6 +118,7 @@ export const initialSystemState: SystemProps = {
|
|||||||
backend: "",
|
backend: "",
|
||||||
docs: "",
|
docs: "",
|
||||||
file: "",
|
file: "",
|
||||||
|
pwa_manifest: "",
|
||||||
},
|
},
|
||||||
site: {
|
site: {
|
||||||
relay_plan: false,
|
relay_plan: false,
|
||||||
|
@ -118,6 +118,10 @@
|
|||||||
--assistant-border-hover: hsla(218, 100%, 64%, .25);
|
--assistant-border-hover: hsla(218, 100%, 64%, .25);
|
||||||
--assistant-shadow: hsla(218, 100%, 64%, .05);
|
--assistant-shadow: hsla(218, 100%, 64%, .05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
@ -371,3 +371,7 @@ input[type="number"] {
|
|||||||
.text-common {
|
.text-common {
|
||||||
color: hsl(var(--text)) !important;
|
color: hsl(var(--text)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-border {
|
||||||
|
border-color: hsl(var(--destructive)) !important;
|
||||||
|
}
|
||||||
|
@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import "@/assets/common/editor.less";
|
import "@/assets/common/editor.less";
|
||||||
import { Textarea } from "./ui/textarea.tsx";
|
import { Textarea } from "./ui/textarea.tsx";
|
||||||
import Markdown from "./Markdown.tsx";
|
import Markdown from "./Markdown.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Toggle } from "./ui/toggle.tsx";
|
import { Toggle } from "./ui/toggle.tsx";
|
||||||
import { mobile } from "@/utils/device.ts";
|
import { mobile } from "@/utils/device.ts";
|
||||||
import { Button } from "./ui/button.tsx";
|
import { Button } from "./ui/button.tsx";
|
||||||
@ -23,6 +23,8 @@ type RichEditorProps = {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
|
|
||||||
|
formatter?: (value: string) => string;
|
||||||
|
isInvalid?: (value: string) => boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
@ -38,7 +40,9 @@ function RichEditor({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
maxLength,
|
maxLength,
|
||||||
|
formatter,
|
||||||
submittable,
|
submittable,
|
||||||
|
isInvalid,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
setOpen,
|
setOpen,
|
||||||
closeOnSubmit,
|
closeOnSubmit,
|
||||||
@ -48,6 +52,13 @@ function RichEditor({
|
|||||||
const [openPreview, setOpenPreview] = useState(!mobile);
|
const [openPreview, setOpenPreview] = useState(!mobile);
|
||||||
const [openInput, setOpenInput] = useState(true);
|
const [openInput, setOpenInput] = useState(true);
|
||||||
|
|
||||||
|
const formattedValue = useMemo(() => {
|
||||||
|
return formatter ? formatter(value) : value;
|
||||||
|
}, [value, formatter]);
|
||||||
|
const invalid = useMemo(() => {
|
||||||
|
return isInvalid ? isInvalid(value) : false;
|
||||||
|
}, [value, isInvalid]);
|
||||||
|
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
if (!input.current) return;
|
if (!input.current) return;
|
||||||
const target = input.current as HTMLElement;
|
const target = input.current as HTMLElement;
|
||||||
@ -133,7 +144,10 @@ function RichEditor({
|
|||||||
<Textarea
|
<Textarea
|
||||||
placeholder={t("chat.placeholder-raw")}
|
placeholder={t("chat.placeholder-raw")}
|
||||||
value={value}
|
value={value}
|
||||||
className={`editor-input`}
|
className={cn(
|
||||||
|
`editor-input transition-all`,
|
||||||
|
invalid && `error-border`,
|
||||||
|
)}
|
||||||
id={`editor`}
|
id={`editor`}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
@ -141,7 +155,7 @@ function RichEditor({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{openPreview && (
|
{openPreview && (
|
||||||
<Markdown className={`editor-preview`} children={value} />
|
<Markdown className={`editor-preview`} children={formattedValue} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -198,3 +212,20 @@ function EditorProvider(props: RichEditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default EditorProvider;
|
export default EditorProvider;
|
||||||
|
|
||||||
|
export function JSONEditorProvider({ ...props }: RichEditorProps) {
|
||||||
|
return (
|
||||||
|
<EditorProvider
|
||||||
|
{...props}
|
||||||
|
formatter={(value) => `\`\`\`json\n${value}\n\`\`\``}
|
||||||
|
isInvalid={(value) => {
|
||||||
|
try {
|
||||||
|
JSON.parse(value);
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -44,7 +44,7 @@ import {
|
|||||||
} from "@/components/ui/dialog.tsx";
|
} from "@/components/ui/dialog.tsx";
|
||||||
import { DialogTitle } from "@radix-ui/react-dialog";
|
import { DialogTitle } from "@radix-ui/react-dialog";
|
||||||
import Require from "@/components/Require.tsx";
|
import Require from "@/components/Require.tsx";
|
||||||
import { Loader2, Settings2 } from "lucide-react";
|
import { Loader2, PencilLine, Settings2 } from "lucide-react";
|
||||||
import { FlexibleTextarea } from "@/components/ui/textarea.tsx";
|
import { FlexibleTextarea } from "@/components/ui/textarea.tsx";
|
||||||
import Tips from "@/components/Tips.tsx";
|
import Tips from "@/components/Tips.tsx";
|
||||||
import { cn } from "@/components/ui/lib/utils.ts";
|
import { cn } from "@/components/ui/lib/utils.ts";
|
||||||
@ -54,6 +54,7 @@ import { allGroups } from "@/utils/groups.ts";
|
|||||||
import { useChannelModels } from "@/admin/hook.tsx";
|
import { useChannelModels } from "@/admin/hook.tsx";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { selectSupportModels } from "@/store/chat.ts";
|
import { selectSupportModels } from "@/store/chat.ts";
|
||||||
|
import { JSONEditorProvider } from "@/components/EditorProvider.tsx";
|
||||||
|
|
||||||
type CompProps<T> = {
|
type CompProps<T> = {
|
||||||
data: T;
|
data: T;
|
||||||
@ -222,6 +223,20 @@ function General({ data, dispatch, onChange }: CompProps<GeneralState>) {
|
|||||||
/>
|
/>
|
||||||
</ParagraphItem>
|
</ParagraphItem>
|
||||||
<ParagraphDescription>{t("admin.system.fileTip")}</ParagraphDescription>
|
<ParagraphDescription>{t("admin.system.fileTip")}</ParagraphDescription>
|
||||||
|
<ParagraphItem>
|
||||||
|
<Label>PWA Manifest</Label>
|
||||||
|
<JSONEditorProvider
|
||||||
|
value={data.pwa_manifest ?? ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
dispatch({ type: "update:general.pwa_manifest", value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button variant={`outline`}>
|
||||||
|
<PencilLine className={`h-4 w-4 mr-1`} />
|
||||||
|
{t("edit")}
|
||||||
|
</Button>
|
||||||
|
</JSONEditorProvider>
|
||||||
|
</ParagraphItem>
|
||||||
<ParagraphFooter>
|
<ParagraphFooter>
|
||||||
<div className={`grow`} />
|
<div className={`grow`} />
|
||||||
<RootDialog />
|
<RootDialog />
|
||||||
|
@ -25,11 +25,12 @@ type ApiInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type generalState struct {
|
type generalState struct {
|
||||||
Title string `json:"title" mapstructure:"title"`
|
Title string `json:"title" mapstructure:"title"`
|
||||||
Logo string `json:"logo" mapstructure:"logo"`
|
Logo string `json:"logo" mapstructure:"logo"`
|
||||||
Backend string `json:"backend" mapstructure:"backend"`
|
Backend string `json:"backend" mapstructure:"backend"`
|
||||||
File string `json:"file" mapstructure:"file"`
|
File string `json:"file" mapstructure:"file"`
|
||||||
Docs string `json:"docs" mapstructure:"docs"`
|
Docs string `json:"docs" mapstructure:"docs"`
|
||||||
|
PWAManifest string `json:"pwa_manifest" mapstructure:"pwamanifest"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type siteState struct {
|
type siteState struct {
|
||||||
@ -98,6 +99,10 @@ func (c *SystemConfig) Load() {
|
|||||||
|
|
||||||
globals.CacheAcceptedExpire = c.GetCacheAcceptedExpire()
|
globals.CacheAcceptedExpire = c.GetCacheAcceptedExpire()
|
||||||
globals.CacheAcceptedSize = c.GetCacheAcceptedSize()
|
globals.CacheAcceptedSize = c.GetCacheAcceptedSize()
|
||||||
|
|
||||||
|
if c.General.PWAManifest == "" {
|
||||||
|
c.General.PWAManifest = utils.ReadPWAManifest()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SystemConfig) SaveConfig() error {
|
func (c *SystemConfig) SaveConfig() error {
|
||||||
@ -133,6 +138,7 @@ func (c *SystemConfig) UpdateConfig(data *SystemConfig) error {
|
|||||||
c.Common = data.Common
|
c.Common = data.Common
|
||||||
|
|
||||||
utils.ApplySeo(c.General.Title, c.General.Logo)
|
utils.ApplySeo(c.General.Title, c.General.Logo)
|
||||||
|
utils.ApplyPWAManifest(c.General.PWAManifest)
|
||||||
|
|
||||||
return c.SaveConfig()
|
return c.SaveConfig()
|
||||||
}
|
}
|
||||||
|
@ -76,6 +76,51 @@ func ApplySeo(title, icon string) {
|
|||||||
globals.Info("[service] seo optimization applied to index.cache.html")
|
globals.Info("[service] seo optimization applied to index.cache.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ApplyPWAManifest(content string) {
|
||||||
|
// pwa manifest rewrite (site.webmanifest -> site.cache.webmanifest)
|
||||||
|
|
||||||
|
if !viper.GetBool("serve_static") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(content) == 0 {
|
||||||
|
// read from site.webmanifest if not provided
|
||||||
|
|
||||||
|
var err error
|
||||||
|
content, err = ReadFile("./app/dist/site.webmanifest")
|
||||||
|
if err != nil {
|
||||||
|
globals.Warn(fmt.Sprintf("[service] failed to read site.webmanifest: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := WriteFile("./app/dist/site.cache.webmanifest", content, true); err != nil {
|
||||||
|
globals.Warn(fmt.Sprintf("[service] failed to write site.cache.webmanifest: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
globals.Info("[service] pwa manifest applied to site.cache.webmanifest")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadPWAManifest() (content string) {
|
||||||
|
// read site.cache.webmanifest content or site.webmanifest if not found
|
||||||
|
|
||||||
|
if !viper.GetBool("serve_static") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if text, err := ReadFile("./app/dist/site.cache.webmanifest"); err == nil && len(text) > 0 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
if text, err := ReadFile("./app/dist/site.webmanifest"); err != nil {
|
||||||
|
globals.Warn(fmt.Sprintf("[service] failed to read site.webmanifest: %s", err.Error()))
|
||||||
|
} else {
|
||||||
|
content = text
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func RegisterStaticRoute(engine *gin.Engine) {
|
func RegisterStaticRoute(engine *gin.Engine) {
|
||||||
// static files are in ~/app/dist
|
// static files are in ~/app/dist
|
||||||
|
|
||||||
@ -92,11 +137,16 @@ func RegisterStaticRoute(engine *gin.Engine) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApplySeo(viper.GetString("system.general.title"), viper.GetString("system.general.logo"))
|
ApplySeo(viper.GetString("system.general.title"), viper.GetString("system.general.logo"))
|
||||||
|
ApplyPWAManifest(viper.GetString("system.general.pwamanifest"))
|
||||||
|
|
||||||
// serve / -> index.cache.html
|
|
||||||
engine.GET("/", func(c *gin.Context) {
|
engine.GET("/", func(c *gin.Context) {
|
||||||
c.File("./app/dist/index.cache.html")
|
c.File("./app/dist/index.cache.html")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
engine.GET("/site.webmanifest", func(c *gin.Context) {
|
||||||
|
c.File("./app/dist/site.cache.webmanifest")
|
||||||
|
})
|
||||||
|
|
||||||
engine.Use(static.Serve("/", static.LocalFile("./app/dist", true)))
|
engine.Use(static.Serve("/", static.LocalFile("./app/dist", true)))
|
||||||
engine.NoRoute(func(c *gin.Context) {
|
engine.NoRoute(func(c *gin.Context) {
|
||||||
c.File("./app/dist/index.cache.html")
|
c.File("./app/dist/index.cache.html")
|
||||||
|
Loading…
Reference in New Issue
Block a user