web: add support for loading theme from file

This commit is contained in:
Abdullah Atta
2023-07-29 16:04:45 +05:00
parent ad67947ad7
commit d10e08a158
6 changed files with 252 additions and 113 deletions

View File

@@ -27,12 +27,11 @@ import Config from "../utils/config";
import { hashNavigate, getCurrentHash } from "../navigation";
import { db } from "./db";
import { sanitizeFilename } from "@notesnook/common";
import { store as userstore } from "../stores/user-store";
import FileSaver from "file-saver";
import { showToast } from "../utils/toast";
import { SUBSCRIPTION_STATUS } from "./constants";
import { showFilePicker } from "../components/editor/picker";
import { showFilePicker } from "../utils/file-picker";
import { logger } from "../utils/logger";
import { PATHS } from "@notesnook/desktop";
import { TaskManager } from "./task-manager";

View File

@@ -25,13 +25,12 @@ import { TaskManager } from "../../common/task-manager";
import { isUserPremium } from "../../hooks/use-is-user-premium";
import fs from "../../interfaces/fs";
import { showToast } from "../../utils/toast";
import { showFilePicker } from "../../utils/file-picker";
const FILE_SIZE_LIMIT = 500 * 1024 * 1024;
const IMAGE_SIZE_LIMIT = 50 * 1024 * 1024;
type MimeType = string; //`${string}/${string}`;
export async function insertAttachment(type: MimeType = "*/*") {
export async function insertAttachment(type = "*/*") {
if (!isUserPremium()) {
await showBuyDialog();
return;
@@ -58,7 +57,7 @@ export async function attachFile(selectedFile: File) {
}
export async function reuploadAttachment(
type: MimeType,
type: string,
expectedFileHash: string
) {
const selectedFile = await showFilePicker({
@@ -120,25 +119,6 @@ async function getEncryptionKey(): Promise<SerializedKey> {
return key;
}
type FilePickerOptions = { acceptedFileTypes: MimeType };
export function showFilePicker({
acceptedFileTypes
}: FilePickerOptions): Promise<File | undefined> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", acceptedFileTypes);
input.dispatchEvent(new MouseEvent("click"));
input.onchange = async function () {
if (!input.files) return resolve(undefined);
const file = input.files[0];
if (!file) return resolve(undefined);
resolve(file);
};
});
}
async function toDataURL(file: File): Promise<string> {
const buffer = await file.arrayBuffer();
const base64 = Buffer.from(buffer).toString("base64");

View File

@@ -21,7 +21,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { Box, Button, Flex, Input, Text } from "@theme-ui/components";
import { CheckCircleOutline, Loading } from "../../../components/icons";
import { THEME_COMPATIBILITY_VERSION } from "@notesnook/theme";
import {
THEME_COMPATIBILITY_VERSION,
getPreviewColors,
validateTheme
} from "@notesnook/theme";
import { debounce } from "@notesnook/common";
import { useStore as useThemeStore } from "../../../stores/theme-store";
import { useStore as useUserStore } from "../../../stores/user-store";
@@ -36,6 +40,7 @@ import { ThemePreview } from "../../../components/theme-preview";
import { VirtuosoGrid } from "react-virtuoso";
import { Loader } from "../../../components/loader";
import { showToast } from "../../../utils/toast";
import { showFilePicker, readFile } from "../../../utils/file-picker";
const ThemesClient = ThemesTRPC.createClient({
links: [
@@ -67,15 +72,15 @@ function ThemesList() {
const [colorScheme, setColorScheme] = useState<"all" | "dark" | "light">(
"all"
);
const [isApplying, setIsApplying] = useState(false);
const setCurrentTheme = useThemeStore((store) => store.setTheme);
const user = useUserStore((store) => store.user);
const darkTheme = useThemeStore((store) => store.darkTheme);
const lightTheme = useThemeStore((store) => store.lightTheme);
const isThemeCurrentlyApplied = useThemeStore(
(store) => store.isThemeCurrentlyApplied
);
const setCurrentTheme = useThemeStore((store) => store.setTheme);
const user = useUserStore((store) => store.user);
useThemeStore((store) => store.darkTheme);
useThemeStore((store) => store.lightTheme);
const filters = [];
if (searchQuery) filters.push({ type: "term" as const, value: searchQuery });
if (colorScheme !== "all")
@@ -87,7 +92,19 @@ function ThemesList() {
compatibilityVersion: THEME_COMPATIBILITY_VERSION,
filters
},
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
{
keepPreviousData: true,
select: (themes) => ({
pageParams: themes.pageParams,
pages: themes.pages.map((page) => ({
nextCursor: page.nextCursor,
themes: page.themes.filter(
(theme) => !isThemeCurrentlyApplied(theme.id)
)
}))
}),
getNextPageParam: (lastPage) => lastPage.nextCursor
}
);
const setTheme = useCallback(
@@ -120,31 +137,65 @@ function ThemesList() {
sx={{ mt: 2 }}
onChange={debounce((e) => setSearchQuery(e.target.value), 500)}
/>
<Flex sx={{ mt: 2, gap: 1 }}>
{COLOR_SCHEMES.map((filter) => (
<Flex sx={{ justifyContent: "space-between", alignItems: "center" }}>
<Flex sx={{ mt: 2, gap: 1 }}>
{COLOR_SCHEMES.map((filter) => (
<Button
key={filter.id}
variant="secondary"
onClick={() => {
setColorScheme(filter.id);
}}
sx={{
borderRadius: 100,
minWidth: 50,
py: 1,
px: 2,
flexShrink: 0,
bg: colorScheme === filter.id ? "shade" : "transparent",
color: colorScheme === filter.id ? "accent" : "paragraph"
}}
>
{filter.title}
</Button>
))}
{themes.isLoading && !themes.isInitialLoading ? (
<Loading color="accent" />
) : null}
</Flex>
<Flex sx={{ mt: 2, gap: 1 }}>
<Button
key={filter.id}
variant="secondary"
onClick={() => {
setColorScheme(filter.id);
onClick={async () => {
const file = await showFilePicker({
acceptedFileTypes: "application/json"
});
if (!file) return;
const theme = JSON.parse(await readFile(file));
const { error } = validateTheme(theme);
if (error) return showToast("error", error);
if (
await showThemeDetails({
...theme,
totalInstalls: 0,
previewColors: getPreviewColors(theme)
})
) {
setTheme(theme);
}
}}
sx={{
borderRadius: 100,
minWidth: 50,
py: 1,
px: 2,
flexShrink: 0,
bg: colorScheme === filter.id ? "shade" : "transparent",
color: colorScheme === filter.id ? "accent" : "paragraph"
px: 3,
flexShrink: 0
}}
>
{filter.title}
Load from file
</Button>
))}
{themes.isLoading && !themes.isInitialLoading ? (
<Loading color="accent" />
) : null}
</Flex>
</Flex>
<Box
sx={{
".virtuoso-grid-list": {
@@ -164,59 +215,39 @@ function ThemesList() {
endReached={() =>
themes.hasNextPage ? themes.fetchNextPage() : null
}
components={{
Header: () => (
<div className="virtuoso-grid-list">
<ThemeItem
theme={{
...darkTheme,
previewColors: getPreviewColors(darkTheme)
}}
isApplied={true}
isApplying={isApplying}
setTheme={setTheme}
/>
<ThemeItem
theme={{
...lightTheme,
previewColors: getPreviewColors(lightTheme)
}}
isApplied={true}
isApplying={isApplying}
setTheme={setTheme}
/>
</div>
)
}}
computeItemKey={(_index, item) => item.id}
itemContent={(_index, theme) => (
<Flex
<ThemeItem
key={theme.id}
sx={{
flexDirection: "column",
flex: 1,
cursor: "pointer",
p: 2,
border: "1px solid transparent",
borderRadius: "default",
":hover": {
bg: "background-secondary",
border: "1px solid var(--border)",
".set-as-button": { visibility: "visible" }
}
}}
onClick={async () => {
if (await showThemeDetails(theme)) {
await setTheme(theme);
}
}}
>
<ThemePreview theme={theme} />
<Text variant="title" sx={{ mt: 1 }}>
{theme.name}
</Text>
<Text variant="body">{theme.authors[0].name}</Text>
<Flex
sx={{ justifyContent: "space-between", alignItems: "center" }}
>
<Text variant="subBody">{theme.totalInstalls} installs</Text>
{isThemeCurrentlyApplied(theme.id) ? (
<CheckCircleOutline color="accent" size={20} />
) : (
<Button
className="set-as-button"
variant="secondary"
sx={{ visibility: "hidden", bg: "background" }}
onClick={(e) => {
e.stopPropagation();
setTheme(theme);
}}
disabled={isApplying}
>
{isApplying ? (
<Loading color="accent" size={18} />
) : (
`Set as ${theme.colorScheme}`
)}
</Button>
)}
</Flex>
</Flex>
theme={theme}
isApplied={false}
isApplying={isApplying}
setTheme={setTheme}
/>
)}
/>
)}
@@ -224,3 +255,69 @@ function ThemesList() {
</>
);
}
type ThemeItemProps = {
theme: ThemeMetadata;
isApplied: boolean;
isApplying: boolean;
setTheme: (theme: ThemeMetadata) => Promise<void>;
};
function ThemeItem(props: ThemeItemProps) {
const { theme, isApplied, isApplying, setTheme } = props;
return (
<Flex
sx={{
flexDirection: "column",
flex: 1,
cursor: "pointer",
p: 2,
border: "1px solid transparent",
borderRadius: "default",
":hover": {
bg: "background-secondary",
border: "1px solid var(--border)",
".set-as-button": { visibility: "visible" }
}
}}
onClick={async () => {
if (await showThemeDetails(theme)) {
await setTheme(theme);
}
}}
>
<ThemePreview theme={theme} />
<Text variant="title" sx={{ mt: 1 }}>
{theme.name}
</Text>
<Text variant="body">{theme.authors[0].name}</Text>
<Flex sx={{ justifyContent: "space-between", alignItems: "center" }}>
<Text variant="subBody">
{theme.colorScheme === "dark" ? "Dark" : "Light"}
&nbsp;&nbsp;
{theme.totalInstalls ? `${theme.totalInstalls} installs` : ""}
</Text>
{isApplied ? (
<CheckCircleOutline color="accent" size={20} />
) : (
<Button
className="set-as-button"
variant="secondary"
sx={{ visibility: "hidden", bg: "background" }}
onClick={(e) => {
e.stopPropagation();
setTheme(theme);
}}
disabled={isApplying}
>
{isApplying ? (
<Loading color="accent" size={18} />
) : (
`Set as ${theme.colorScheme}`
)}
</Button>
)}
</Flex>
</Flex>
);
}

View File

@@ -63,20 +63,34 @@ function ThemeDetailsDialog(props: ThemeDetailsDialogProps) {
<Text variant="subBody" sx={{ fontSize: "subtitle" }}>
{theme.authors.map((author) => author.name).join(", ")}
</Text>
<Text variant="subBody" sx={{ fontSize: "subtitle", mt: 2 }}>
{theme.totalInstalls} installs
</Text>
{theme.totalInstalls && (
<Text variant="subBody" sx={{ fontSize: "subtitle" }}>
{theme.totalInstalls} installs
</Text>
)}
<Text variant="subBody" sx={{ fontSize: "subtitle" }}>
Licensed under {theme.license}
</Text>
<Link
href={theme.homepage}
target="_blank"
variant="text.subBody"
sx={{ fontSize: "subtitle", color: "accent" }}
>
Website
</Link>
<Flex sx={{ gap: 1, mt: 1 }}>
<Link
href={theme.homepage}
target="_blank"
variant="text.subBody"
sx={{ fontSize: "subtitle", color: "accent" }}
>
Website
</Link>
{theme.sourceURL && (
<Link
href={theme.sourceURL}
target="_blank"
variant="text.subBody"
sx={{ fontSize: "subtitle", color: "accent" }}
>
Source
</Link>
)}
</Flex>
</Flex>
</Dialog>
);

View File

@@ -0,0 +1,49 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
type FilePickerOptions = { acceptedFileTypes: string };
export function showFilePicker({
acceptedFileTypes
}: FilePickerOptions): Promise<File | undefined> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", acceptedFileTypes);
input.dispatchEvent(new MouseEvent("click"));
input.onchange = async function () {
if (!input.files) return resolve(undefined);
const file = input.files[0];
if (!file) return resolve(undefined);
resolve(file);
};
});
}
export async function readFile(file: File): Promise<string> {
const reader = new FileReader();
return await new Promise<string>((resolve, reject) => {
reader.addEventListener("load", (event) => {
const text = event.target?.result as string;
if (!text) return reject("FileReader failed to load file.");
resolve(text);
});
reader.readAsText(file);
});
}

View File

@@ -35,8 +35,8 @@ import {
} from "@notesnook/theme";
export type CompiledThemeDefinition = ThemeDefinition & {
sourceURL: string;
totalInstalls: number;
sourceURL?: string;
totalInstalls?: number;
previewColors: PreviewColors;
};