mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-02-24 04:00:59 +01:00
web: add support for loading theme from file
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
49
apps/web/src/utils/file-picker.ts
Normal file
49
apps/web/src/utils/file-picker.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -35,8 +35,8 @@ import {
|
||||
} from "@notesnook/theme";
|
||||
|
||||
export type CompiledThemeDefinition = ThemeDefinition & {
|
||||
sourceURL: string;
|
||||
totalInstalls: number;
|
||||
sourceURL?: string;
|
||||
totalInstalls?: number;
|
||||
previewColors: PreviewColors;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user