Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] Add info/docs page for each plugin #432

Closed
wants to merge 44 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
bc04606
Update helpers.py
PartyWumpus Apr 22, 2023
e92a372
Update loader.py
PartyWumpus Apr 23, 2023
badcf02
Update TitleView.tsx
PartyWumpus Apr 23, 2023
e5eafb4
Update plugin-loader.tsx
PartyWumpus Apr 23, 2023
ab92b54
Add files via upload
PartyWumpus Apr 23, 2023
ab308dd
Rename docs.tsx to Docs.tsx
PartyWumpus Apr 23, 2023
d524f27
Update plugin-loader.tsx
PartyWumpus Apr 23, 2023
8303ea5
Update Docs.tsx
PartyWumpus Apr 23, 2023
12f6f29
Update TitleView.tsx
PartyWumpus Apr 23, 2023
c463d56
Update helpers.py
PartyWumpus Apr 23, 2023
4cea2ee
Update loader.py
PartyWumpus Apr 23, 2023
a127da1
Update Docs.tsx
PartyWumpus Apr 23, 2023
1eed6ea
Move info button + add focusable to it
PartyWumpus Apr 23, 2023
7a47316
Partial support for full screen readme. scrolling is broken in it
PartyWumpus Apr 23, 2023
de90dc0
use remarkGfm because i can
PartyWumpus Apr 23, 2023
4d07edd
Make links work
PartyWumpus Apr 24, 2023
bc2e9a5
Add scrolling support + other stuff
PartyWumpus Apr 25, 2023
3a0bfc7
give credit
PartyWumpus Apr 25, 2023
62d6777
wrong file woops
PartyWumpus Apr 25, 2023
2681064
credit properly
PartyWumpus Apr 25, 2023
7358318
add scrolling to solo pages
PartyWumpus Apr 26, 2023
6de4f58
Merge branch 'SteamDeckHomebrew:main' into main
PartyWumpus May 28, 2023
a486cca
Merge branch 'main' into main
PartyWumpus Jun 2, 2023
3567ad4
Add files via upload
PartyWumpus Jun 15, 2023
1a873e8
Delete frontend/src/components/settings/pages/testing directory
PartyWumpus Jun 15, 2023
bcba268
Merge branch 'SteamDeckHomebrew:main' into main
PartyWumpus Jul 12, 2023
bc37932
add python-frontmatter as a requirement
PartyWumpus Jul 16, 2023
3364d80
Update loader.py
PartyWumpus Jul 16, 2023
55eb392
importing things is useful actually
PartyWumpus Jul 16, 2023
66f2497
swap two lines around
PartyWumpus Jul 16, 2023
d3816b6
Update loader.py
PartyWumpus Jul 16, 2023
e4b0bb6
"borrow" tabmaster's css
PartyWumpus Jul 16, 2023
814c94f
Update loader.py
PartyWumpus Jul 16, 2023
5b3e28b
Update Docs.tsx
PartyWumpus Jul 17, 2023
fe1aa4a
Update loader.py
PartyWumpus Jul 17, 2023
8b852b6
Update Docs.tsx
PartyWumpus Jul 17, 2023
438d903
Update Docs.tsx
PartyWumpus Jul 17, 2023
e20823a
Update loader.py
PartyWumpus Jul 17, 2023
34572d1
remove file extension from default name for a file
PartyWumpus Jul 18, 2023
d92e7df
add image support maybe
PartyWumpus Aug 28, 2023
2f555a4
Merge branch 'main' into main
PartyWumpus Sep 10, 2023
5673125
add `onDocsClick` param for custom TitleViews
beebls Sep 14, 2023
d6ae395
switch `Router` to `Navigation`
beebls Sep 14, 2023
443b9f5
Merge pull request #1 from beebls/main
PartyWumpus Sep 14, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def get_csrf_token():

@middleware
async def csrf_middleware(request, handler):
if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/legacy/") or str(request.rel_url).startswith("/steam_resource/") or str(request.rel_url).startswith("/frontend/") or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)):
if str(request.method) == "OPTIONS" or request.headers.get('Authentication') == csrf_token or str(request.rel_url) == "/auth/token" or str(request.rel_url).startswith("/plugins/load_main/") or str(request.rel_url).startswith("/static/") or str(request.rel_url).startswith("/legacy/") or str(request.rel_url).startswith("/steam_resource/") or str(request.rel_url).startswith("/frontend/") or str(request.rel_url).startswith("/docs/") or assets_regex.match(str(request.rel_url)) or frontend_regex.match(str(request.rel_url)):
return await handler(request)
return Response(text='Forbidden', status='403')

Expand Down
72 changes: 71 additions & 1 deletion backend/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from os import listdir, path
from pathlib import Path
from traceback import print_exc
from json import load

from aiohttp import web
from os.path import exists
Expand All @@ -13,6 +14,8 @@
from injector import get_tab, get_gamepadui_tab
from plugin import PluginWrapper

import frontmatter

class FileChangeHandler(RegexMatchingEventHandler):
def __init__(self, queue, plugin_path) -> None:
super().__init__(regexes=[r'^.*?dist\/index\.js$', r'^.*?main\.py$'])
Expand Down Expand Up @@ -78,6 +81,7 @@ def __init__(self, server_instance, plugin_path, loop, live_reload=False) -> Non
server_instance.add_routes([
web.get("/frontend/{path:.*}", self.handle_frontend_assets),
web.get("/locales/{path:.*}", self.handle_frontend_locales),
web.get("/docs/{plugin_name}/{language}", self.get_plugin_documentation),
web.get("/plugins", self.get_plugins),
web.get("/plugins/{plugin_name}/frontend_bundle", self.handle_frontend_bundle),
web.post("/plugins/{plugin_name}/methods/{method_name}", self.handle_plugin_method_call),
Expand Down Expand Up @@ -119,6 +123,72 @@ def handle_plugin_frontend_assets(self, request):
file = path.join(self.plugin_path, plugin.plugin_directory, "dist/assets", request.match_info["path"])

return web.FileResponse(file, headers={"Cache-Control": "no-cache"})

def get_plugin_documentation(self, request):
plugin_name, language = request.match_info["plugin_name"], request.match_info["language"]
plugin_path = path.join(self.plugin_path, self.plugins[plugin_name].plugin_directory)
docs_path = path.join(plugin_path, "docs")
self.logger.info(f"Loading docs for {plugin_name} in {language}")

if not exists(docs_path):
try:
with open(path.join(plugin_path, "README.md")) as f:
return web.json_response([{"title":"readme","text":f.read()}])
except:
logger.error(f"Failed to load readme file for {plugin_name} at {plugin_path}")

docs = [] # [{"title":"readable name", "text":"marked up file"},'separator',...]

config = {"default_language": "en-US", "include_readme": "False", "file_list":None, "use_translation":None}
try:
with open(path.join(docs_path, "docs.json")) as f:
config_file = load(f)
for key in config:
if key in config_file:
config[key] = config_file[key]
except:
self.logger.warning(f"unable to load docs.json for {plugin_name} at {plugin_path}")

if config["use_translation"] == None:
if exists(path.join(docs_path, config["default_language"])):
config["use_translation"] = "True"
else:
config["use_translation"] = "False"
if config["use_translation"] == "True": docs_file_path = path.join(docs_path, language)
elif config["use_translation"] == "False": docs_file_path = docs_path

if config["file_list"] == None:
files = listdir(docs_file_path)
config["file_list"] = filter(lambda x: (x[-3:] == ".md"),files)


for filename in config["file_list"]:
if filename == "seperator":
docs.append('separator')
else:
try:
if config["use_translation"] == "True" and not exists(path.join(docs_file_path,filename)):
data = frontmatter.load(path.join(docs_path, config["default_language"], filename))
else:
data = frontmatter.load(path.join(docs_file_path,filename))
text = data.content.replace("/decky/assets", f"http://127.0.0.1:1337/plugins/{plugin_name.replace(' ', '%20')}/assets")
docs.append({
"title": data.get("title", filename[:-3]),
"text": text
})
except:
self.logger.warning(f"unable to load file {filename} for {plugin_name} at {docs_file_path}")

if config["include_readme"] == "True":
try:
with open(path.join(plugin_path, "README.md")) as f:
text = f.read().replace("/decky/assets", f"http://127.0.0.1:1337/plugins/{plugin_name.replace(' ', '%20')}/assets")
docs.append({"title":"readme","text": text})
except:
self.logger.warning(f"unable to load the readme for {plugin_name} at {plugin_path}")

return web.json_response(docs)


def handle_frontend_bundle(self, request):
plugin = self.plugins[request.match_info["plugin_name"]]
Expand Down Expand Up @@ -225,4 +295,4 @@ async def handle_backend_reload_request(self, request):

await self.reload_queue.put((plugin.file, plugin.plugin_directory))

return web.Response(status=200)
return web.Response(status=200)
99 changes: 99 additions & 0 deletions frontend/src/components/Docs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { SidebarNavigation, SteamSpinner, useParams } from 'decky-frontend-lib';
import i18n from 'i18next';
import { VFC, useEffect, useState } from 'react';
import { lazy } from 'react';

import { ScrollArea, Scrollable, scrollableRef } from './Scrollable';

const MarkdownRenderer = lazy(() => import('./Markdown'));

const DocsPage: VFC<{ content: string }> = ({ content }) => {
const ref = scrollableRef();

return (
<>
<style>
{`
.decky-docs-markdown p {white-space: pre-wrap}
.decky-docs-markdown a {text-decoration: none;}
.decky-docs-markdown code {color: #f1ac4f; padding: 2px 4px; border-radius: 4px;}
.decky-docs-markdown table {border: 1px solid; border-collapse: collapse;}
.decky-docs-markdown th {padding: 0 7px; border: 1px solid;}
.decky-docs-markdown td {padding: 0 7px; border: 1px solid;}
.decky-docs-markdown tr:nth-child(odd) {background-color: #1B2838;}
.decky-docs-markdown > .Panel.Focusable.gpfocuswithin {background-color: #868da117;}
.decky-docs-markdown img {max-width: 588px;}
`}
</style>
<Scrollable ref={ref}>
<ScrollArea scrollable={ref} noFocusRing={true}>
<MarkdownRenderer className="decky-docs-markdown" children={content} />
</ScrollArea>
</Scrollable>
</>
);
};

interface DocsPage {
title: string;
text: string;
}

const StorePage: VFC<{}> = () => {
const [docs, setDocs] = useState<(DocsPage | 'separator')[] | null>(null);
const { plugin } = useParams<{ plugin: string }>();

useEffect(() => {
(async () => {
setDocs(
await (
await fetch(`http://127.0.0.1:1337/docs/${plugin}/${i18n.resolvedLanguage}`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authentication: window.deckyAuthToken,
},
})
).json(),
);
})();
}, []);

return (
<>
{!docs ? (
<div style={{ height: '100%' }}>
<SteamSpinner />
</div>
) : docs.length == 1 ? (
<div
style={{
padding: 'calc(12px + 1.4vw) 2.8vw',
paddingTop: 'calc( 24px + var(--basicui-header-height, 0px) )',
background: '#0e141b',
}}
>
<DocsPage content={docs[Object.keys(docs)[0]]['text']} />
</div>
) : (
<SidebarNavigation
title={plugin}
showTitle={true}
pages={docs.map((file) =>
file == 'separator'
? 'separator'
: {
title: file['title'],
content: <DocsPage content={file['text']} />,
route: `/decky/docs/${plugin}/${file['title']}`,
hideTitle: true,
},
)}
/>
)}
</>
);
};

export default StorePage;
2 changes: 1 addition & 1 deletion frontend/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const Markdown: FunctionComponent<MarkdownProps> = (props) => {
}}
style={{ display: 'inline' }}
>
<a ref={aRef} {...nodeProps.node.properties}>
<a ref={aRef} target="_blank" {...nodeProps.node.properties}>
{nodeProps.children}
</a>
</Focusable>
Expand Down
115 changes: 115 additions & 0 deletions frontend/src/components/Scrollable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Focusable, FocusableProps, GamepadButton, GamepadEvent, ServerAPI } from 'decky-frontend-lib';
// this file was mostly (the good bits) made by davocarli#7308
// it should probably be moved to DFL eventually
import { FC, ForwardRefExoticComponent } from 'react';
import React, { useRef } from 'react';

const DEFAULTSCROLLSPEED = 50;

export interface ScrollableElement extends HTMLDivElement {}

export function scrollableRef() {
return useRef<ScrollableElement>(null);
}

export const Scrollable: ForwardRefExoticComponent<any> = React.forwardRef((props, ref) => {
if (!props.style) {
props.style = {};
}
// props.style.minHeight = '100%';
// props.style.maxHeight = '80%';
props.style.height = '85vh'; // was 95vh previously!
props.style.overflowY = 'scroll';

return (
<React.Fragment>
<div ref={ref} {...props} />
</React.Fragment>
);
});

interface ScrollAreaProps extends FocusableProps {
scrollable: React.RefObject<ScrollableElement>;
scrollSpeed?: number;
serverApi?: ServerAPI;
noFocusRing?: boolean;
}

// const writeLog = async (serverApi: ServerAPI, content: any) => {
// let text = `${content}`
// serverApi.callPluginMethod<{ content: string }>("log", { content: text })
// }

const scrollOnDirection = (
e: GamepadEvent,
ref: React.RefObject<ScrollableElement>,
amt: number,
prev: React.RefObject<HTMLDivElement>,
next: React.RefObject<HTMLDivElement>,
) => {
let childNodes = ref.current?.childNodes;
let currentIndex = null;
childNodes?.forEach((node, i) => {
if (node == e.currentTarget) {
currentIndex = i;
}
});

// @ts-ignore
let pos = e.currentTarget?.getBoundingClientRect();
let out = ref.current?.getBoundingClientRect();

if (e.detail.button == GamepadButton.DIR_DOWN) {
if (
out?.bottom != undefined &&
pos.bottom <= out.bottom &&
currentIndex != null &&
childNodes != undefined &&
currentIndex + 1 < childNodes.length
) {
next.current?.focus();
} else {
ref.current?.scrollBy({ top: amt, behavior: 'auto' });
}
} else if (e.detail.button == GamepadButton.DIR_UP) {
if (
out?.top != undefined &&
pos.top >= out.top &&
currentIndex != null &&
childNodes != undefined &&
currentIndex - 1 >= 0
) {
prev.current?.focus();
} else {
ref.current?.scrollBy({ top: -amt, behavior: 'auto' });
}
} else if (e.detail.button == GamepadButton.DIR_LEFT) {
throw 'this is not a real error, just a (temporary?) workaround to make navigation work in docs';
}
};

export const ScrollArea: FC<ScrollAreaProps> = (props) => {
let scrollSpeed = DEFAULTSCROLLSPEED;
if (props.scrollSpeed) {
scrollSpeed = props.scrollSpeed;
}

const prevFocus = useRef<HTMLDivElement>(null);
const nextFocus = useRef<HTMLDivElement>(null);

props.onActivate = (e) => {
const ele = e.currentTarget as HTMLElement;
ele.focus();
};
props.onGamepadDirection = (e) => {
scrollOnDirection(e, props.scrollable, scrollSpeed, prevFocus, nextFocus);
};

return (
<React.Fragment>
<Focusable noFocusRing={props.noFocusRing ?? false} ref={prevFocus} children={[]} onActivate={() => {}} />
<Focusable {...props} />
<Focusable noFocusRing={props.noFocusRing ?? false} ref={nextFocus} children={[]} onActivate={() => {}} />
</React.Fragment>
);
};
Loading