fix: upload template (#6021)

* fix: upload template

* fix: scale thumb
This commit is contained in:
Kilu.He 2024-08-23 16:28:46 +08:00 committed by GitHub
parent 8ae67c5098
commit 3fa72106e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 3697 additions and 245 deletions

View File

@ -68,7 +68,7 @@ const createServer = async (req: Request) => {
logger.info(`Request URL: ${hostname}${reqUrl.pathname}`);
if (['/after-payment', '/login'].includes(reqUrl.pathname)) {
if (['/after-payment', '/login', '/as-template'].includes(reqUrl.pathname)) {
timer();
const htmlData = fs.readFileSync(indexPath, 'utf8');
const $ = load(htmlData);

View File

@ -73,6 +73,7 @@
"react-datepicker": "^4.23.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.13",
"react-hook-form": "^7.52.2",
"react-hot-toast": "^2.4.1",
"react-i18next": "^14.1.0",
"react-katex": "^3.0.1",
@ -137,6 +138,7 @@
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.13",
"axios-mock-adapter": "^2.0.0",
"babel-jest": "^29.6.2",
"chalk": "^4.1.2",
"cheerio": "1.0.0-rc.12",

View File

@ -152,6 +152,9 @@ dependencies:
react-error-boundary:
specifier: ^4.0.13
version: 4.0.13(react@18.2.0)
react-hook-form:
specifier: ^7.52.2
version: 7.52.2(react@18.2.0)
react-hot-toast:
specifier: ^2.4.1
version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0)
@ -340,6 +343,9 @@ devDependencies:
autoprefixer:
specifier: ^10.4.13
version: 10.4.13(postcss@8.4.21)
axios-mock-adapter:
specifier: ^2.0.0
version: 2.0.0(axios@1.7.2)
babel-jest:
specifier: ^29.6.2
version: 29.6.2(@babel/core@7.24.3)
@ -4945,6 +4951,16 @@ packages:
/aws4@1.12.0:
resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==}
/axios-mock-adapter@2.0.0(axios@1.7.2):
resolution: {integrity: sha512-D/K0J5Zm6KvaMTnsWrBQZWLzKN9GxUFZEa0mx2qeEHXDeTugCoplWehy8y36dj5vuSjhe1u/Dol8cZ8lzzmDew==}
peerDependencies:
axios: '>= 0.17.0'
dependencies:
axios: 1.7.2
fast-deep-equal: 3.1.3
is-buffer: 2.0.5
dev: true
/axios@1.7.2:
resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==}
dependencies:
@ -4953,7 +4969,6 @@ packages:
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
dev: false
/b4a@1.6.6:
resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==}
@ -6709,7 +6724,6 @@ packages:
peerDependenciesMeta:
debug:
optional: true
dev: false
/for-each@0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@ -7289,6 +7303,11 @@ packages:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
dev: false
/is-buffer@2.0.5:
resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
engines: {node: '>=4'}
dev: true
/is-callable@1.2.7:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'}
@ -9258,7 +9277,6 @@ packages:
/proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
dev: false
/psl@1.9.0:
resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
@ -9512,6 +9530,15 @@ packages:
/react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
/react-hook-form@7.52.2(react@18.2.0):
resolution: {integrity: sha512-pqfPEbERnxxiNMPd0bzmt1tuaPcVccywFDpyk2uV5xCIBphHV5T8SVnX9/o3kplPE1zzKt77+YIoq+EMwJp56A==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
dependencies:
react: 18.2.0
dev: false
/react-hot-toast@2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==}
engines: {node: '>=10'}

View File

@ -1,7 +1,7 @@
import { GetViewRowsMap, LoadView, LoadViewMeta } from '@/application/collab.type';
import { db } from '@/application/db';
import { ViewMeta } from '@/application/db/tables/view_metas';
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import { useLiveQuery } from 'dexie-react-hooks';
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
@ -9,6 +9,7 @@ import { useNavigate } from 'react-router-dom';
export interface PublishContextType {
namespace: string;
publishName: string;
isTemplateThumb?: boolean;
viewMeta?: ViewMeta;
toView: (viewId: string) => Promise<void>;
loadViewMeta: LoadViewMeta;
@ -23,10 +24,12 @@ export const PublishProvider = ({
children,
namespace,
publishName,
isTemplateThumb,
}: {
children: React.ReactNode;
namespace: string;
publishName: string;
isTemplateThumb?: boolean;
}) => {
const viewMeta = useLiveQuery(async () => {
const name = `${namespace}_${publishName}`;
@ -87,7 +90,7 @@ export const PublishProvider = ({
return Promise.reject(e);
}
},
[navigate, service]
[navigate, service],
);
const loadViewMeta = useCallback(
@ -124,7 +127,7 @@ export const PublishProvider = ({
return Promise.reject(e);
}
},
[service]
[service],
);
const getViewRowsMap = useCallback(
@ -148,7 +151,7 @@ export const PublishProvider = ({
return Promise.reject(e);
}
},
[service]
[service],
);
const loadView = useCallback(
@ -173,7 +176,7 @@ export const PublishProvider = ({
return Promise.reject(e);
}
},
[service]
[service],
);
useEffect(() => {
@ -195,6 +198,7 @@ export const PublishProvider = ({
toView,
namespace,
publishName,
isTemplateThumb,
}}
>
{children}
@ -202,6 +206,6 @@ export const PublishProvider = ({
);
};
export function usePublishContext() {
export function usePublishContext () {
return useContext(PublishContext);
}

View File

@ -4,6 +4,13 @@ import { initGrantService, refreshToken } from '@/application/services/js-servic
import { blobToBytes } from '@/application/services/js-services/http/utils';
import { AFCloudConfig } from '@/application/services/services.type';
import { getTokenParsed, invalidToken } from '@/application/session/token';
import {
Template,
TemplateCategory,
TemplateCategoryFormValues,
TemplateCreator, TemplateCreatorFormValues, TemplateSummary,
UploadTemplatePayload,
} from '@/application/template.type';
import { FolderView, User, Workspace } from '@/application/types';
import axios, { AxiosInstance } from 'axios';
import dayjs from 'dayjs';
@ -12,13 +19,16 @@ export * from './gotrue';
let axiosInstance: AxiosInstance | null = null;
export function initAPIService(config: AFCloudConfig) {
export function initAPIService (config: AFCloudConfig) {
if (axiosInstance) {
return;
}
axiosInstance = axios.create({
baseURL: config.baseURL,
headers: {
'Content-Type': 'application/json',
},
});
initGrantService(config.gotrueURL);
@ -27,10 +37,6 @@ export function initAPIService(config: AFCloudConfig) {
async (config) => {
const token = getTokenParsed();
Object.assign(config.headers, {
'Content-Type': 'application/json',
});
if (!token) {
return config;
}
@ -56,7 +62,7 @@ export function initAPIService(config: AFCloudConfig) {
},
(error) => {
return Promise.reject(error);
}
},
);
axiosInstance.interceptors.response.use(async (response) => {
@ -83,7 +89,7 @@ export function initAPIService(config: AFCloudConfig) {
});
}
export async function signInWithUrl(url: string) {
export async function signInWithUrl (url: string) {
const hash = new URL(url).hash;
if (!hash) {
@ -100,7 +106,7 @@ export async function signInWithUrl(url: string) {
await refreshToken(refresh_token);
}
export async function verifyToken(accessToken: string) {
export async function verifyToken (accessToken: string) {
const url = `/api/user/verify/${accessToken}`;
const response = await axiosInstance?.get<{
code: number;
@ -119,7 +125,7 @@ export async function verifyToken(accessToken: string) {
return Promise.reject(data);
}
export async function getCurrentUser(): Promise<User> {
export async function getCurrentUser (): Promise<User> {
const url = '/api/user/profile';
const response = await axiosInstance?.get<{
code: number;
@ -155,14 +161,14 @@ export async function getCurrentUser(): Promise<User> {
return Promise.reject(data);
}
export async function getPublishViewMeta(namespace: string, publishName: string) {
export async function getPublishViewMeta (namespace: string, publishName: string) {
const url = `/api/workspace/published/${namespace}/${publishName}`;
const response = await axiosInstance?.get(url);
return response?.data;
}
export async function getPublishViewBlob(namespace: string, publishName: string) {
export async function getPublishViewBlob (namespace: string, publishName: string) {
const url = `/api/workspace/published/${namespace}/${publishName}/blob`;
const response = await axiosInstance?.get(url, {
responseType: 'blob',
@ -171,7 +177,7 @@ export async function getPublishViewBlob(namespace: string, publishName: string)
return blobToBytes(response?.data);
}
export async function getPublishView(publishNamespace: string, publishName: string) {
export async function getPublishView (publishNamespace: string, publishName: string) {
const meta = await getPublishViewMeta(publishNamespace, publishName);
const blob = await getPublishViewBlob(publishNamespace, publishName);
@ -207,7 +213,7 @@ export async function getPublishView(publishNamespace: string, publishName: stri
}
}
export async function getPublishInfoWithViewId(viewId: string) {
export async function getPublishInfoWithViewId (viewId: string) {
const url = `/api/workspace/published-info/${viewId}`;
const response = await axiosInstance?.get<{
code: number;
@ -227,7 +233,7 @@ export async function getPublishInfoWithViewId(viewId: string) {
return Promise.reject(data);
}
export async function getPublishViewComments(viewId: string): Promise<GlobalComment[]> {
export async function getPublishViewComments (viewId: string): Promise<GlobalComment[]> {
const url = `/api/workspace/published-info/${viewId}/comment`;
const response = await axiosInstance?.get<{
code: number;
@ -276,7 +282,7 @@ export async function getPublishViewComments(viewId: string): Promise<GlobalComm
return Promise.reject(data);
}
export async function getReactions(viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
export async function getReactions (viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
let url = `/api/workspace/published-info/${viewId}/reaction`;
if (commentId) {
@ -327,7 +333,7 @@ export async function getReactions(viewId: string, commentId?: string): Promise<
return Promise.reject(data);
}
export async function createGlobalCommentOnPublishView(viewId: string, content: string, replyCommentId?: string) {
export async function createGlobalCommentOnPublishView (viewId: string, content: string, replyCommentId?: string) {
const url = `/api/workspace/published-info/${viewId}/comment`;
const response = await axiosInstance?.post<{ code: number; message: string }>(url, {
content,
@ -341,7 +347,7 @@ export async function createGlobalCommentOnPublishView(viewId: string, content:
return Promise.reject(response?.data.message);
}
export async function deleteGlobalCommentOnPublishView(viewId: string, commentId: string) {
export async function deleteGlobalCommentOnPublishView (viewId: string, commentId: string) {
const url = `/api/workspace/published-info/${viewId}/comment`;
const response = await axiosInstance?.delete<{ code: number; message: string }>(url, {
data: {
@ -356,7 +362,7 @@ export async function deleteGlobalCommentOnPublishView(viewId: string, commentId
return Promise.reject(response?.data.message);
}
export async function addReaction(viewId: string, commentId: string, reactionType: string) {
export async function addReaction (viewId: string, commentId: string, reactionType: string) {
const url = `/api/workspace/published-info/${viewId}/reaction`;
const response = await axiosInstance?.post<{ code: number; message: string }>(url, {
comment_id: commentId,
@ -370,7 +376,7 @@ export async function addReaction(viewId: string, commentId: string, reactionTyp
return Promise.reject(response?.data.message);
}
export async function removeReaction(viewId: string, commentId: string, reactionType: string) {
export async function removeReaction (viewId: string, commentId: string, reactionType: string) {
const url = `/api/workspace/published-info/${viewId}/reaction`;
const response = await axiosInstance?.delete<{ code: number; message: string }>(url, {
data: {
@ -386,7 +392,7 @@ export async function removeReaction(viewId: string, commentId: string, reaction
return Promise.reject(response?.data.message);
}
export async function getWorkspaces(): Promise<Workspace[]> {
export async function getWorkspaces (): Promise<Workspace[]> {
const query = new URLSearchParams({
include_member_count: 'true',
});
@ -436,7 +442,7 @@ export interface WorkspaceFolder {
children: WorkspaceFolder[];
}
function iterateFolder(folder: WorkspaceFolder): FolderView {
function iterateFolder (folder: WorkspaceFolder): FolderView {
return {
id: folder.view_id,
name: folder.name,
@ -450,7 +456,7 @@ function iterateFolder(folder: WorkspaceFolder): FolderView {
};
}
export async function getWorkspaceFolder(workspaceId: string): Promise<FolderView> {
export async function getWorkspaceFolder (workspaceId: string): Promise<FolderView> {
const url = `/api/workspace/${workspaceId}/folder`;
const response = await axiosInstance?.get<{
code: number;
@ -473,7 +479,7 @@ export interface DuplicatePublishViewPayload {
dest_view_id: string;
}
export async function duplicatePublishView(workspaceId: string, payload: DuplicatePublishViewPayload) {
export async function duplicatePublishView (workspaceId: string, payload: DuplicatePublishViewPayload) {
const url = `/api/workspace/${workspaceId}/published-duplicate`;
const res = await axiosInstance?.post<{
@ -487,3 +493,247 @@ export async function duplicatePublishView(workspaceId: string, payload: Duplica
return Promise.reject(res?.data.message);
}
export async function createTemplate (template: UploadTemplatePayload) {
const url = '/api/template-center/template';
const response = await axiosInstance?.post<{
code: number;
message: string;
}>(url, template);
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function updateTemplate (viewId: string, template: UploadTemplatePayload) {
const url = `/api/template-center/template/${viewId}`;
const response = await axiosInstance?.put<{
code: number;
message: string;
}>(url, template);
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function getTemplates ({
categoryId,
nameContains,
}: {
categoryId?: string;
nameContains?: string;
}) {
const url = `/api/template-center/template`;
const response = await axiosInstance?.get<{
code: number;
data?: {
templates: TemplateSummary[];
};
message: string;
}>(url, {
params: {
category_id: categoryId,
name_contains: nameContains,
},
});
const data = response?.data;
if (data?.code === 0 && data.data) {
return data.data.templates;
}
return Promise.reject(data);
}
export async function getTemplateById (viewId: string) {
const url = `/api/template-center/template/${viewId}`;
const response = await axiosInstance?.get<{
code: number;
data?: Template;
message: string;
}>(url);
const data = response?.data;
if (data?.code === 0 && data.data) {
return data.data;
}
return Promise.reject(data);
}
export async function deleteTemplate (viewId: string) {
const url = `/api/template-center/template/${viewId}`;
const response = await axiosInstance?.delete<{
code: number;
message: string;
}>(url);
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function getTemplateCategories () {
const url = '/api/template-center/category';
const response = await axiosInstance?.get<{
code: number;
data?: {
categories: TemplateCategory[]
};
message: string;
}>(url);
const data = response?.data;
if (data?.code === 0 && data.data) {
return data.data.categories;
}
return Promise.reject(data);
}
export async function addTemplateCategory (category: TemplateCategoryFormValues) {
const url = '/api/template-center/category';
const response = await axiosInstance?.post<{
code: number;
message: string;
}>(url, category);
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function updateTemplateCategory (id: string, category: TemplateCategoryFormValues) {
const url = `/api/template-center/category/${id}`;
const response = await axiosInstance?.put<{
code: number;
message: string;
}>(url, category);
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function deleteTemplateCategory (categoryId: string) {
const url = `/api/template-center/category/${categoryId}`;
const response = await axiosInstance?.delete<{
code: number;
message: string;
}>(url);
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function getTemplateCreators () {
const url = '/api/template-center/creator';
const response = await axiosInstance?.get<{
code: number;
data?: {
creators: TemplateCreator[];
};
message: string;
}>(url);
const data = response?.data;
if (data?.code === 0 && data.data) {
return data.data.creators;
}
return Promise.reject(data);
}
export async function createTemplateCreator (creator: TemplateCreatorFormValues) {
const url = '/api/template-center/creator';
const response = await axiosInstance?.post<{
code: number;
message: string;
}>(url, creator);
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function updateTemplateCreator (creatorId: string, creator: TemplateCreatorFormValues) {
const url = `/api/template-center/creator/${creatorId}`;
const response = await axiosInstance?.put<{
code: number;
message: string;
}>(url, creator);
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function deleteTemplateCreator (creatorId: string) {
const url = `/api/template-center/creator/${creatorId}`;
const response = await axiosInstance?.delete<{
code: number;
message: string;
}>(url);
if (response?.data.code === 0) {
return;
}
return Promise.reject(response?.data.message);
}
export async function uploadFileToCDN (file: File) {
const url = '/api/template-center/avatar';
const formData = new FormData();
console.log(file);
formData.append('avatar', file);
const response = await axiosInstance?.request<{
code: number;
data?: {
file_id: string;
};
message: string;
}>({
method: 'PUT',
url,
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
});
const data = response?.data;
if (data?.code === 0 && data.data) {
return axiosInstance?.defaults.baseURL + '/api/template-center/avatar/' + data.data.file_id;
}
return Promise.reject(data);
}

View File

@ -1,4 +1,4 @@
export function blobToBytes(blob: Blob): Promise<Uint8Array> {
export function blobToBytes (blob: Blob): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const reader = new FileReader();

View File

@ -13,6 +13,11 @@ import { APIService } from '@/application/services/js-services/http';
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { emit, EventType } from '@/application/session';
import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in';
import {
TemplateCategoryFormValues,
TemplateCreatorFormValues,
UploadTemplatePayload,
} from '@/application/template.type';
import { nanoid } from 'nanoid';
import * as Y from 'yjs';
import { DuplicatePublishView } from '@/application/types';
@ -36,15 +41,15 @@ export class AFClientService implements AFService {
private cacheDatabaseRowFolder: Map<string, Y.Map<YDoc>> = new Map();
constructor(config: AFServiceConfig) {
constructor (config: AFServiceConfig) {
APIService.initAPIService(config.cloudConfig);
}
getClientId() {
getClientId () {
return this.clientId;
}
async getPublishViewMeta(namespace: string, publishName: string) {
async getPublishViewMeta (namespace: string, publishName: string) {
const name = `${namespace}_${publishName}`;
const isLoaded = this.publishViewLoaded.has(name);
@ -56,7 +61,7 @@ export class AFClientService implements AFService {
namespace,
publishName,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK,
);
if (!viewMeta) {
@ -66,7 +71,7 @@ export class AFClientService implements AFService {
return viewMeta;
}
async getPublishView(namespace: string, publishName: string) {
async getPublishView (namespace: string, publishName: string) {
const name = `${namespace}_${publishName}`;
const isLoaded = this.publishViewLoaded.has(name);
@ -91,7 +96,7 @@ export class AFClientService implements AFService {
namespace,
publishName,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK,
);
if (!isLoaded) {
@ -103,7 +108,7 @@ export class AFClientService implements AFService {
return doc;
}
async getPublishDatabaseViewRows(namespace: string, publishName: string) {
async getPublishDatabaseViewRows (namespace: string, publishName: string) {
const name = `${namespace}_${publishName}`;
if (!this.publishViewLoaded.has(name) || !this.cacheDatabaseRowDocMap.has(name)) {
@ -133,7 +138,7 @@ export class AFClientService implements AFService {
};
}
async getPublishInfo(viewId: string) {
async getPublishInfo (viewId: string) {
if (this.publishViewInfo.has(viewId)) {
return this.publishViewInfo.get(viewId) as {
namespace: string;
@ -159,7 +164,7 @@ export class AFClientService implements AFService {
return data;
}
async loginAuth(url: string) {
async loginAuth (url: string) {
try {
console.log('loginAuth', url);
await APIService.signInWithUrl(url);
@ -173,45 +178,45 @@ export class AFClientService implements AFService {
}
@withSignIn()
async signInMagicLink({ email }: { email: string; redirectTo: string }) {
async signInMagicLink ({ email }: { email: string; redirectTo: string }) {
return await APIService.signInWithMagicLink(email, AUTH_CALLBACK_URL);
}
@withSignIn()
async signInGoogle(_: { redirectTo: string }) {
async signInGoogle (_: { redirectTo: string }) {
return APIService.signInGoogle(AUTH_CALLBACK_URL);
}
@withSignIn()
async signInGithub(_: { redirectTo: string }) {
async signInGithub (_: { redirectTo: string }) {
return APIService.signInGithub(AUTH_CALLBACK_URL);
}
@withSignIn()
async signInDiscord(_: { redirectTo: string }) {
async signInDiscord (_: { redirectTo: string }) {
return APIService.signInDiscord(AUTH_CALLBACK_URL);
}
async getWorkspaces() {
async getWorkspaces () {
const data = APIService.getWorkspaces();
return data;
}
async getWorkspaceFolder(workspaceId: string) {
async getWorkspaceFolder (workspaceId: string) {
const data = await APIService.getWorkspaceFolder(workspaceId);
return data;
}
async getCurrentUser() {
async getCurrentUser () {
const data = await APIService.getCurrentUser();
await APIService.getWorkspaces();
return data;
}
async duplicatePublishView(params: DuplicatePublishView) {
async duplicatePublishView (params: DuplicatePublishView) {
return APIService.duplicatePublishView(params.workspaceId, {
dest_view_id: params.spaceViewId,
published_view_id: params.viewId,
@ -219,27 +224,87 @@ export class AFClientService implements AFService {
});
}
createCommentOnPublishView(viewId: string, content: string, replyCommentId: string | undefined): Promise<void> {
createCommentOnPublishView (viewId: string, content: string, replyCommentId: string | undefined): Promise<void> {
return APIService.createGlobalCommentOnPublishView(viewId, content, replyCommentId);
}
deleteCommentOnPublishView(viewId: string, commentId: string): Promise<void> {
deleteCommentOnPublishView (viewId: string, commentId: string): Promise<void> {
return APIService.deleteGlobalCommentOnPublishView(viewId, commentId);
}
getPublishViewGlobalComments(viewId: string): Promise<GlobalComment[]> {
getPublishViewGlobalComments (viewId: string): Promise<GlobalComment[]> {
return APIService.getPublishViewComments(viewId);
}
getPublishViewReactions(viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
getPublishViewReactions (viewId: string, commentId?: string): Promise<Record<string, Reaction[]>> {
return APIService.getReactions(viewId, commentId);
}
addPublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise<void> {
addPublishViewReaction (viewId: string, commentId: string, reactionType: string): Promise<void> {
return APIService.addReaction(viewId, commentId, reactionType);
}
removePublishViewReaction(viewId: string, commentId: string, reactionType: string): Promise<void> {
removePublishViewReaction (viewId: string, commentId: string, reactionType: string): Promise<void> {
return APIService.removeReaction(viewId, commentId, reactionType);
}
async getTemplateCategories () {
return APIService.getTemplateCategories();
}
async getTemplateCreators () {
return APIService.getTemplateCreators();
}
async createTemplate (template: UploadTemplatePayload) {
return APIService.createTemplate(template);
}
async updateTemplate (id: string, template: UploadTemplatePayload) {
return APIService.updateTemplate(id, template);
}
async getTemplateById (id: string) {
return APIService.getTemplateById(id);
}
async getTemplates (params: {
categoryId?: string;
nameContains?: string;
}) {
return APIService.getTemplates(params);
}
async deleteTemplate (id: string) {
return APIService.deleteTemplate(id);
}
async addTemplateCategory (category: TemplateCategoryFormValues) {
return APIService.addTemplateCategory(category);
}
async updateTemplateCategory (categoryId: string, category: TemplateCategoryFormValues) {
return APIService.updateTemplateCategory(categoryId, category);
}
async deleteTemplateCategory (categoryId: string) {
return APIService.deleteTemplateCategory(categoryId);
}
async updateTemplateCreator (creatorId: string, creator: TemplateCreatorFormValues) {
return APIService.updateTemplateCreator(creatorId, creator);
}
async createTemplateCreator (creator: TemplateCreatorFormValues) {
return APIService.createTemplateCreator(creator);
}
async deleteTemplateCreator (creatorId: string) {
return APIService.deleteTemplateCreator(creatorId);
}
async uploadFileToCDN (file: File) {
return APIService.uploadFileToCDN(file);
}
}

View File

@ -1,6 +1,13 @@
import { YDoc } from '@/application/collab.type';
import { GlobalComment, Reaction } from '@/application/comment.type';
import { ViewMeta } from '@/application/db/tables/view_metas';
import {
Template,
TemplateCategory,
TemplateCategoryFormValues,
TemplateCreator, TemplateCreatorFormValues, TemplateSummary,
UploadTemplatePayload,
} from '@/application/template.type';
import * as Y from 'yjs';
import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types';
@ -24,11 +31,12 @@ export interface PublishService {
getPublishDatabaseViewRows: (
namespace: string,
publishName: string,
rowIds?: string[]
rowIds?: string[],
) => Promise<{
rows: Y.Map<YDoc>;
destroy: () => void;
}>;
getPublishViewGlobalComments: (viewId: string) => Promise<GlobalComment[]>;
createCommentOnPublishView: (viewId: string, content: string, replyCommentId?: string) => Promise<void>;
deleteCommentOnPublishView: (viewId: string, commentId: string) => Promise<void>;
@ -46,4 +54,22 @@ export interface PublishService {
getWorkspaceFolder: (workspaceId: string) => Promise<FolderView>;
getCurrentUser: () => Promise<User>;
duplicatePublishView: (params: DuplicatePublishView) => Promise<void>;
getTemplateCategories: () => Promise<TemplateCategory[]>;
addTemplateCategory: (category: TemplateCategoryFormValues) => Promise<void>;
deleteTemplateCategory: (categoryId: string) => Promise<void>;
getTemplateCreators: () => Promise<TemplateCreator[]>;
createTemplateCreator: (creator: TemplateCreatorFormValues) => Promise<void>;
deleteTemplateCreator: (creatorId: string) => Promise<void>;
getTemplateById: (id: string) => Promise<Template>;
getTemplates: (params: {
categoryId?: string;
nameContains?: string;
}) => Promise<TemplateSummary[]>;
deleteTemplate: (id: string) => Promise<void>;
createTemplate: (template: UploadTemplatePayload) => Promise<void>;
updateTemplate: (id: string, template: UploadTemplatePayload) => Promise<void>;
updateTemplateCategory: (categoryId: string, category: TemplateCategoryFormValues) => Promise<void>;
updateTemplateCreator: (creatorId: string, creator: TemplateCreatorFormValues) => Promise<void>;
uploadFileToCDN: (file: File) => Promise<string>;
}

View File

@ -1,6 +1,12 @@
import { YDoc } from '@/application/collab.type';
import { GlobalComment, Reaction } from '@/application/comment.type';
import { AFService } from '@/application/services/services.type';
import {
Template, TemplateCategory,
TemplateCategoryFormValues, TemplateCreator,
TemplateCreatorFormValues, TemplateSummary,
UploadTemplatePayload,
} from '@/application/template.type';
import { nanoid } from 'nanoid';
import { YMap } from 'yjs/dist/src/types/YMap';
import { DuplicatePublishView, FolderView, User, Workspace } from '@/application/types';
@ -10,45 +16,45 @@ export class AFClientService implements AFService {
private clientId: string = 'tauri';
async getPublishView(_namespace: string, _publishName: string) {
async getPublishView (_namespace: string, _publishName: string) {
return Promise.reject('Method not implemented');
}
async getPublishInfo(_viewId: string) {
async getPublishInfo (_viewId: string) {
return Promise.reject('Method not implemented');
}
async getPublishViewMeta(_namespace: string, _publishName: string) {
async getPublishViewMeta (_namespace: string, _publishName: string) {
return Promise.reject('Method not implemented');
}
getClientId(): string {
getClientId (): string {
return '';
}
loginAuth(_: string): Promise<void> {
loginAuth (_: string): Promise<void> {
return Promise.reject('Method not implemented');
}
signInDiscord(_params: { redirectTo: string }): Promise<void> {
signInDiscord (_params: { redirectTo: string }): Promise<void> {
return Promise.reject('Method not implemented');
}
signInGithub(_params: { redirectTo: string }): Promise<void> {
signInGithub (_params: { redirectTo: string }): Promise<void> {
return Promise.reject('Method not implemented');
}
signInGoogle(_params: { redirectTo: string }): Promise<void> {
signInGoogle (_params: { redirectTo: string }): Promise<void> {
return Promise.reject('Method not implemented');
}
signInMagicLink(_params: { email: string; redirectTo: string }): Promise<void> {
signInMagicLink (_params: { email: string; redirectTo: string }): Promise<void> {
return Promise.reject('Method not implemented');
}
getPublishDatabaseViewRows(
getPublishDatabaseViewRows (
_namespace: string,
_publishName: string
_publishName: string,
): Promise<{
rows: YMap<YDoc>;
destroy: () => void;
@ -56,43 +62,99 @@ export class AFClientService implements AFService {
return Promise.reject('Method not implemented');
}
duplicatePublishView(_params: DuplicatePublishView): Promise<void> {
duplicatePublishView (_params: DuplicatePublishView): Promise<void> {
return Promise.reject('Method not implemented');
}
getCurrentUser(): Promise<User> {
getCurrentUser (): Promise<User> {
return Promise.reject('Method not implemented');
}
getWorkspaceFolder(_workspaceId: string): Promise<FolderView> {
getWorkspaceFolder (_workspaceId: string): Promise<FolderView> {
return Promise.reject('Method not implemented');
}
getWorkspaces(): Promise<Workspace[]> {
getWorkspaces (): Promise<Workspace[]> {
return Promise.reject('Method not implemented');
}
addPublishViewReaction(_viewId: string, _commentId: string, _reactionType: string): Promise<void> {
addPublishViewReaction (_viewId: string, _commentId: string, _reactionType: string): Promise<void> {
return Promise.reject('Method not implemented');
}
createCommentOnPublishView(_viewId: string, _content: string, _replyCommentId: string | undefined): Promise<void> {
createCommentOnPublishView (_viewId: string, _content: string, _replyCommentId: string | undefined): Promise<void> {
return Promise.reject('Method not implemented');
}
deleteCommentOnPublishView(_viewId: string, _commentId: string): Promise<void> {
deleteCommentOnPublishView (_viewId: string, _commentId: string): Promise<void> {
return Promise.reject('Method not implemented');
}
getPublishViewGlobalComments(_viewId: string): Promise<GlobalComment[]> {
getPublishViewGlobalComments (_viewId: string): Promise<GlobalComment[]> {
return Promise.resolve([]);
}
getPublishViewReactions(_viewId: string, _commentId: string | undefined): Promise<Record<string, Reaction[]>> {
getPublishViewReactions (_viewId: string, _commentId: string | undefined): Promise<Record<string, Reaction[]>> {
return Promise.reject('Method not implemented');
}
removePublishViewReaction(_viewId: string, _commentId: string, _reactionType: string): Promise<void> {
removePublishViewReaction (_viewId: string, _commentId: string, _reactionType: string): Promise<void> {
return Promise.reject('Method not implemented');
}
addTemplateCategory (_category: TemplateCategoryFormValues): Promise<void> {
return Promise.reject('Method not implemented');
}
createTemplate (_template: UploadTemplatePayload): Promise<void> {
return Promise.reject('Method not implemented');
}
createTemplateCreator (_creator: TemplateCreatorFormValues): Promise<void> {
return Promise.reject('Method not implemented');
}
deleteTemplate (_id: string): Promise<void> {
return Promise.reject('Method not implemented');
}
deleteTemplateCategory (_categoryId: string): Promise<void> {
return Promise.reject('Method not implemented');
}
deleteTemplateCreator (_creatorId: string): Promise<void> {
return Promise.reject('Method not implemented');
}
getTemplateById (_id: string): Promise<Template> {
return Promise.reject('Method not implemented');
}
getTemplateCategories (): Promise<TemplateCategory[]> {
return Promise.resolve([]);
}
getTemplateCreators (): Promise<TemplateCreator[]> {
return Promise.resolve([]);
}
getTemplates (_params: { categoryId?: string; nameContains?: string }): Promise<TemplateSummary[]> {
return Promise.resolve([]);
}
updateTemplate (_id: string, _template: UploadTemplatePayload): Promise<void> {
return Promise.reject('Method not implemented');
}
updateTemplateCategory (_categoryId: string, _category: TemplateCategoryFormValues): Promise<void> {
return Promise.reject('Method not implemented');
}
updateTemplateCreator (_creatorId: string, _creator: TemplateCreatorFormValues): Promise<void> {
return Promise.reject('Method not implemented');
}
uploadFileToCDN (_file: File): Promise<string> {
return Promise.resolve('');
}
}

View File

@ -24,43 +24,44 @@ const localChanges = new WeakMap<YjsEditor, LocalChange[]>();
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const YjsEditor = {
connected(editor: YjsEditor): boolean {
connected (editor: YjsEditor): boolean {
return connectSet.has(editor);
},
connect(editor: YjsEditor): void {
connect (editor: YjsEditor): void {
editor.connect();
},
disconnect(editor: YjsEditor): void {
disconnect (editor: YjsEditor): void {
editor.disconnect();
},
applyRemoteEvents(editor: YjsEditor, events: Array<YEvent>, transaction: Transaction): void {
applyRemoteEvents (editor: YjsEditor, events: Array<YEvent>, transaction: Transaction): void {
editor.applyRemoteEvents(events, transaction);
},
localChanges(editor: YjsEditor): LocalChange[] {
localChanges (editor: YjsEditor): LocalChange[] {
return localChanges.get(editor) ?? [];
},
storeLocalChange(editor: YjsEditor, op: Operation): void {
storeLocalChange (editor: YjsEditor, op: Operation): void {
editor.storeLocalChange(op);
},
flushLocalChanges(editor: YjsEditor): void {
flushLocalChanges (editor: YjsEditor): void {
editor.flushLocalChanges();
},
};
export function withYjs<T extends Editor>(
export function withYjs<T extends Editor> (
editor: T,
doc: Y.Doc,
opts?: {
localOrigin: CollabOrigin;
}
readSummary?: boolean;
},
): T & YjsEditor {
const { localOrigin = CollabOrigin.Local } = opts ?? {};
const { localOrigin = CollabOrigin.Local, readSummary } = opts ?? {};
const e = editor as T & YjsEditor;
const { apply, onChange } = e;
@ -73,7 +74,11 @@ export function withYjs<T extends Editor>(
return;
}
if (readSummary) {
e.children = content.children.slice(0, 10);
} else {
e.children = content.children;
}
Editor.normalize(editor, { force: true });
};

View File

@ -0,0 +1,83 @@
export enum TemplateCategoryType {
ByUseCase,
ByFeature,
}
export enum TemplateIcon {
project = 'project',
engineering = 'engineering',
startups = 'startups',
schools = 'schools',
marketing = 'marketing',
management = 'management',
humanResources = 'human-resources',
sales = 'sales',
teamMeetings = 'team-meetings',
ai = 'ai',
docs = 'docs',
wiki = 'wiki',
database = 'database',
kanban = 'kanban',
}
export interface TemplateCategoryFormValues {
name: string;
icon: TemplateIcon;
bg_color: string;
description: string;
category_type: TemplateCategoryType,
priority: number;
}
export interface TemplateCategory extends TemplateCategoryFormValues {
id: string;
}
export interface TemplateCreatorFormValues {
name: string;
avatar_url: string;
account_links?: {
link_type: string;
url: string;
}[];
}
export interface TemplateCreator {
id: string;
name: string;
avatar_url: string;
upload_template_count?: number;
account_links?: {
link_type: string;
url: string;
}[];
}
export interface UploadTemplatePayload {
view_id: string;
name: string;
description: string;
view_url: string;
about: string;
category_ids: string[];
creator_id: string;
is_new_template: boolean;
is_featured: boolean;
related_view_ids: string[];
}
export interface TemplateSummary {
view_id: string;
name: string;
description: string;
view_url: string;
categories: TemplateCategory[];
creator: TemplateCreator;
is_new_template: boolean;
is_featured: boolean;
}
export interface Template extends TemplateSummary {
about: string;
related_templates: TemplateSummary[];
}

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7.5" y="4" width="1" height="8" rx="0.5" fill="currentColor"/>
<rect x="12" y="7.5" width="1" height="8" rx="0.5" transform="rotate(90 12 7.5)" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Book">
<path id="Vector"
d="M16.25 1.875H5.61719C4.24766 1.875 3.13047 2.98594 3.125 4.35469V16.8578C3.125 16.8633 3.125 16.8695 3.125 16.875V17.5C3.125 17.8453 3.40469 18.125 3.75 18.125H15C15.3453 18.125 15.625 17.8453 15.625 17.5C15.625 17.1547 15.3453 16.875 15 16.875H4.375V16.8609C4.37812 16.1813 4.93125 15.6281 5.60938 15.625H16.25C16.5953 15.625 16.875 15.3453 16.875 15V2.5C16.875 2.15469 16.5953 1.875 16.25 1.875ZM15.625 14.375H5.60703C5.15859 14.3766 4.73828 14.4977 4.375 14.707V4.36172C4.37656 4.02969 4.50703 3.71875 4.74219 3.48516C4.97656 3.25313 5.28672 3.125 5.61953 3.125H15.625V14.375Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 790 B

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="ChatCircleText">
<path id="Vector"
d="M13.125 8.7496C13.125 8.91536 13.0592 9.07433 12.9419 9.19154C12.8247 9.30875 12.6658 9.3746 12.5 9.3746H7.5C7.33424 9.3746 7.17527 9.30875 7.05806 9.19154C6.94085 9.07433 6.875 8.91536 6.875 8.7496C6.875 8.58384 6.94085 8.42487 7.05806 8.30766C7.17527 8.19045 7.33424 8.1246 7.5 8.1246H12.5C12.6658 8.1246 12.8247 8.19045 12.9419 8.30766C13.0592 8.42487 13.125 8.58384 13.125 8.7496ZM12.5 10.6246H7.5C7.33424 10.6246 7.17527 10.6904 7.05806 10.8077C6.94085 10.9249 6.875 11.0838 6.875 11.2496C6.875 11.4154 6.94085 11.5743 7.05806 11.6915C7.17527 11.8087 7.33424 11.8746 7.5 11.8746H12.5C12.6658 11.8746 12.8247 11.8087 12.9419 11.6915C13.0592 11.5743 13.125 11.4154 13.125 11.2496C13.125 11.0838 13.0592 10.9249 12.9419 10.8077C12.8247 10.6904 12.6658 10.6246 12.5 10.6246ZM18.125 9.9996C18.1253 11.4024 17.7624 12.7813 17.0717 14.0022C16.381 15.2231 15.3859 16.2444 14.1834 16.9667C12.9808 17.6889 11.6118 18.0875 10.2095 18.1237C8.80719 18.1598 7.41942 17.8323 6.18125 17.173L3.52109 18.0598C3.30085 18.1332 3.0645 18.1439 2.83854 18.0905C2.61257 18.0372 2.40593 17.922 2.24176 17.7578C2.07759 17.5937 1.96239 17.387 1.90906 17.1611C1.85573 16.9351 1.86639 16.6987 1.93984 16.4785L2.82656 13.8183C2.24699 12.7287 1.92328 11.5213 1.88 10.2879C1.83672 9.05441 2.075 7.82731 2.57677 6.6997C3.07854 5.57209 3.8306 4.57362 4.77587 3.78006C5.72114 2.9865 6.83477 2.41872 8.03224 2.11981C9.22971 1.8209 10.4795 1.79873 11.6868 2.05496C12.8942 2.3112 14.0272 2.83911 15.0001 3.59864C15.9729 4.35816 16.7599 5.32933 17.3014 6.43842C17.8428 7.54752 18.1245 8.76539 18.125 9.9996ZM16.875 9.9996C16.8747 8.94501 16.6318 7.90462 16.1651 6.95893C15.6983 6.01324 15.0203 5.18759 14.1834 4.54587C13.3466 3.90415 12.3733 3.46356 11.3389 3.25818C10.3045 3.0528 9.23671 3.08814 8.21815 3.36147C7.1996 3.6348 6.25757 4.13878 5.46496 4.83443C4.67235 5.53009 4.0504 6.39876 3.64724 7.37324C3.24407 8.34772 3.07048 9.40189 3.13992 10.4542C3.20935 11.5065 3.51994 12.5287 4.04766 13.4418C4.09195 13.5184 4.11945 13.6036 4.12834 13.6917C4.13723 13.7798 4.1273 13.8688 4.09922 13.9527L3.125 16.8746L6.04688 15.9004C6.11052 15.8787 6.17729 15.8676 6.24453 15.8676C6.3543 15.8678 6.46208 15.8969 6.55703 15.9519C7.60219 16.5566 8.78817 16.8754 9.99566 16.8762C11.2031 16.8769 12.3895 16.5597 13.4354 15.9563C14.4814 15.3529 15.3499 14.4847 15.9537 13.439C16.5575 12.3933 16.8753 11.2071 16.875 9.9996Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,12 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1068_187542)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.88381 0.0605549C6.89196 -0.05967 5.88886 0.163768 5.04181 0.693609C4.25349 1.18671 3.64467 1.91745 3.30135 2.77774C2.87262 2.82504 2.45508 2.94713 2.06781 3.13888C1.63324 3.35405 1.24531 3.6527 0.92616 4.01779C0.281611 4.75512 -0.0436335 5.7183 0.0219743 6.69544C0.087582 7.67257 0.538668 8.58362 1.276 9.22817C1.65376 9.55839 2.09079 9.8048 2.55861 9.95778C2.77382 9.08534 3.5616 8.43839 4.50049 8.43839H5.00049V7.93839C5.00049 6.83382 5.89592 5.93839 7.00049 5.93839C8.10506 5.93839 9.00049 6.83382 9.00049 7.93839V8.43839H9.50049C10.474 8.43839 11.2851 9.13397 11.4638 10.0553C12.0005 9.93492 12.5014 9.67863 12.9162 9.30595C13.5042 8.77758 13.878 8.05195 13.9669 7.26645C14.0558 6.48096 13.8536 5.69013 13.3985 5.04371C12.9811 4.45071 12.3775 4.01701 11.6866 3.80943C11.5613 2.88615 11.1436 2.02521 10.4923 1.35427C9.79633 0.637401 8.87567 0.18078 7.88381 0.0605549ZM3.75049 10.4384C3.75049 10.0242 4.08628 9.68839 4.50049 9.68839H6.25049V7.93839C6.25049 7.52417 6.58628 7.18839 7.00049 7.18839C7.4147 7.18839 7.75049 7.52417 7.75049 7.93839V9.68839H9.50049C9.9147 9.68839 10.2505 10.0242 10.2505 10.4384C10.2505 10.8526 9.9147 11.1884 9.50049 11.1884H7.75049V12.9384C7.75049 13.3526 7.4147 13.6884 7.00049 13.6884C6.58628 13.6884 6.25049 13.3526 6.25049 12.9384V11.1884H4.50049C4.08628 11.1884 3.75049 10.8526 3.75049 10.4384Z"
fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_1068_187542">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,12 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Columns">
<g id="Vector">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8.125 2.5C8.81536 2.5 9.375 3.05964 9.375 3.75V16.25C9.375 16.9404 8.81536 17.5 8.125 17.5H5C4.30964 17.5 3.75 16.9404 3.75 16.25V3.75C3.75 3.05964 4.30964 2.5 5 2.5H8.125ZM8.125 16.25V3.75H5V16.25H8.125Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M15 2.5C15.6904 2.5 16.25 3.05964 16.25 3.75V16.25C16.25 16.9404 15.6904 17.5 15 17.5H11.875C11.1846 17.5 10.625 16.9404 10.625 16.25V3.75C10.625 3.05964 11.1846 2.5 11.875 2.5H15ZM15 16.25V3.75H11.875V16.25H15Z"
fill="currentColor"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 828 B

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="CurrencyCircleDollar">
<path id="Vector"
d="M10 1.875C8.39303 1.875 6.82214 2.35152 5.486 3.24431C4.14985 4.1371 3.10844 5.40605 2.49348 6.8907C1.87852 8.37535 1.71762 10.009 2.03112 11.5851C2.34463 13.1612 3.11846 14.6089 4.25476 15.7452C5.39106 16.8815 6.8388 17.6554 8.4149 17.9689C9.99099 18.2824 11.6247 18.1215 13.1093 17.5065C14.594 16.8916 15.8629 15.8502 16.7557 14.514C17.6485 13.1779 18.125 11.607 18.125 10C18.1227 7.84581 17.266 5.78051 15.7427 4.25727C14.2195 2.73403 12.1542 1.87727 10 1.875ZM10 16.875C8.64026 16.875 7.31105 16.4718 6.18046 15.7164C5.04987 14.9609 4.16868 13.8872 3.64833 12.6309C3.12798 11.3747 2.99183 9.99237 3.2571 8.65875C3.52238 7.32513 4.17716 6.10013 5.13864 5.13864C6.10013 4.17716 7.32514 3.52237 8.65876 3.2571C9.99238 2.99183 11.3747 3.12798 12.631 3.64833C13.8872 4.16868 14.9609 5.04987 15.7164 6.18045C16.4718 7.31104 16.875 8.64025 16.875 10C16.8729 11.8227 16.1479 13.5702 14.8591 14.8591C13.5702 16.1479 11.8227 16.8729 10 16.875ZM13.125 11.5625C13.125 12.1427 12.8945 12.6991 12.4843 13.1093C12.0741 13.5195 11.5177 13.75 10.9375 13.75H10.625V14.375C10.625 14.5408 10.5592 14.6997 10.4419 14.8169C10.3247 14.9342 10.1658 15 10 15C9.83424 15 9.67527 14.9342 9.55806 14.8169C9.44085 14.6997 9.375 14.5408 9.375 14.375V13.75H8.125C7.95924 13.75 7.80027 13.6842 7.68306 13.5669C7.56585 13.4497 7.5 13.2908 7.5 13.125C7.5 12.9592 7.56585 12.8003 7.68306 12.6831C7.80027 12.5658 7.95924 12.5 8.125 12.5H10.9375C11.1861 12.5 11.4246 12.4012 11.6004 12.2254C11.7762 12.0496 11.875 11.8111 11.875 11.5625C11.875 11.3139 11.7762 11.0754 11.6004 10.8996C11.4246 10.7238 11.1861 10.625 10.9375 10.625H9.0625C8.48234 10.625 7.92594 10.3945 7.51571 9.9843C7.10547 9.57406 6.875 9.01766 6.875 8.4375C6.875 7.85734 7.10547 7.30094 7.51571 6.8907C7.92594 6.48047 8.48234 6.25 9.0625 6.25H9.375V5.625C9.375 5.45924 9.44085 5.30027 9.55806 5.18306C9.67527 5.06585 9.83424 5 10 5C10.1658 5 10.3247 5.06585 10.4419 5.18306C10.5592 5.30027 10.625 5.45924 10.625 5.625V6.25H11.875C12.0408 6.25 12.1997 6.31585 12.3169 6.43306C12.4342 6.55027 12.5 6.70924 12.5 6.875C12.5 7.04076 12.4342 7.19973 12.3169 7.31694C12.1997 7.43415 12.0408 7.5 11.875 7.5H9.0625C8.81386 7.5 8.57541 7.59877 8.39959 7.77459C8.22378 7.9504 8.125 8.18886 8.125 8.4375C8.125 8.68614 8.22378 8.9246 8.39959 9.10041C8.57541 9.27623 8.81386 9.375 9.0625 9.375H10.9375C11.5177 9.375 12.0741 9.60547 12.4843 10.0157C12.8945 10.4259 13.125 10.9823 13.125 11.5625Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Database">
<path id="Vector"
d="M10 1.875C5.79453 1.875 2.5 3.79688 2.5 6.25V13.75C2.5 16.2031 5.79453 18.125 10 18.125C14.2055 18.125 17.5 16.2031 17.5 13.75V6.25C17.5 3.79688 14.2055 1.875 10 1.875ZM16.25 10C16.25 10.7516 15.6344 11.518 14.5617 12.1031C13.3539 12.7617 11.7336 13.125 10 13.125C8.26641 13.125 6.64609 12.7617 5.43828 12.1031C4.36563 11.518 3.75 10.7516 3.75 10V8.7C5.08281 9.87187 7.36172 10.625 10 10.625C12.6383 10.625 14.9172 9.86875 16.25 8.7V10ZM5.43828 4.14688C6.64609 3.48828 8.26641 3.125 10 3.125C11.7336 3.125 13.3539 3.48828 14.5617 4.14688C15.6344 4.73203 16.25 5.49844 16.25 6.25C16.25 7.00156 15.6344 7.76797 14.5617 8.35312C13.3539 9.01172 11.7336 9.375 10 9.375C8.26641 9.375 6.64609 9.01172 5.43828 8.35312C4.36563 7.76797 3.75 7.00156 3.75 6.25C3.75 5.49844 4.36563 4.73203 5.43828 4.14688ZM14.5617 15.8531C13.3539 16.5117 11.7336 16.875 10 16.875C8.26641 16.875 6.64609 16.5117 5.43828 15.8531C4.36563 15.268 3.75 14.5016 3.75 13.75V12.45C5.08281 13.6219 7.36172 14.375 10 14.375C12.6383 14.375 14.9172 13.6187 16.25 12.45V13.75C16.25 14.5016 15.6344 15.268 14.5617 15.8531Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 13H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.8849 3.36289C11.1173 3.13054 11.4324 3 11.761 3C11.9237 3 12.0848 3.03205 12.2351 3.09431C12.3855 3.15658 12.5221 3.24784 12.6371 3.36289C12.7522 3.47794 12.8434 3.61453 12.9057 3.76485C12.968 3.91517 13 4.07629 13 4.23899C13 4.4017 12.968 4.56281 12.9057 4.71314C12.8434 4.86346 12.7522 5.00004 12.6371 5.11509L5.33627 12.4159L3 13L3.58407 10.6637L10.8849 3.36289Z"
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 661 B

View File

@ -0,0 +1,11 @@
<svg fill="currentColor" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 310 310" xml:space="preserve">
<g id="XMLID_834_">
<path id="XMLID_835_" d="M81.703,165.106h33.981V305c0,2.762,2.238,5,5,5h57.616c2.762,0,5-2.238,5-5V165.765h39.064
c2.54,0,4.677-1.906,4.967-4.429l5.933-51.502c0.163-1.417-0.286-2.836-1.234-3.899c-0.949-1.064-2.307-1.673-3.732-1.673h-44.996
V71.978c0-9.732,5.24-14.667,15.576-14.667c1.473,0,29.42,0,29.42,0c2.762,0,5-2.239,5-5V5.037c0-2.762-2.238-5-5-5h-40.545
C187.467,0.023,186.832,0,185.896,0c-7.035,0-31.488,1.381-50.804,19.151c-21.402,19.692-18.427,43.27-17.716,47.358v37.752H81.703
c-2.762,0-5,2.238-5,5v50.844C76.703,162.867,78.941,165.106,81.703,165.106z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 818 B

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="GraduationCap">
<path id="Vector"
d="M19.6686 6.94833L10.2936 1.94833C10.2031 1.90018 10.1023 1.875 9.9998 1.875C9.89736 1.875 9.79648 1.90018 9.70605 1.94833L0.331055 6.94833C0.231058 7.00163 0.14743 7.0811 0.0891172 7.17825C0.0308042 7.27541 0 7.38659 0 7.4999C0 7.61321 0.0308042 7.72439 0.0891172 7.82154C0.14743 7.91869 0.231058 7.99817 0.331055 8.05146L2.4998 9.20849V12.9913C2.49916 13.2983 2.61214 13.5947 2.81699 13.8233C3.84043 14.9632 6.1334 16.8749 9.9998 16.8749C11.2818 16.8855 12.5542 16.6533 13.7498 16.1905V18.7499C13.7498 18.9157 13.8157 19.0746 13.9329 19.1918C14.0501 19.309 14.209 19.3749 14.3748 19.3749C14.5406 19.3749 14.6995 19.309 14.8167 19.1918C14.934 19.0746 14.9998 18.9157 14.9998 18.7499V15.5866C15.8148 15.1161 16.5513 14.5212 17.1826 13.8233C17.3875 13.5947 17.5005 13.2983 17.4998 12.9913V9.20849L19.6686 8.05146C19.7686 7.99817 19.8522 7.91869 19.9105 7.82154C19.9688 7.72439 19.9996 7.61321 19.9996 7.4999C19.9996 7.38659 19.9688 7.27541 19.9105 7.17825C19.8522 7.0811 19.7686 7.00163 19.6686 6.94833ZM9.9998 15.6249C6.61934 15.6249 4.63105 13.9733 3.7498 12.9913V9.8749L9.70605 13.0515C9.79648 13.0996 9.89736 13.1248 9.9998 13.1248C10.1023 13.1248 10.2031 13.0996 10.2936 13.0515L13.7498 11.2085V14.8288C12.7654 15.2882 11.5248 15.6249 9.9998 15.6249ZM16.2498 12.9882C15.8752 13.4039 15.456 13.7772 14.9998 14.1015V10.5413L16.2498 9.8749V12.9882ZM14.6873 9.29208L14.6701 9.28193L10.2951 6.94833C10.1491 6.87378 9.97974 6.85954 9.82338 6.9087C9.66702 6.95786 9.53623 7.06647 9.45918 7.21114C9.38213 7.35581 9.365 7.52495 9.41147 7.68213C9.45794 7.83931 9.56429 7.97194 9.70762 8.05146L13.3592 9.9999L9.9998 11.7913L1.95293 7.4999L9.9998 3.20849L18.0467 7.4999L14.6873 9.29208Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="inbox">
<path id="Vector"
d="M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3ZM19 19H5V16H8.56C9.25 17.19 10.53 18 12.01 18C13.49 18 14.76 17.19 15.46 16H19V19ZM19 14H14.01C14.01 15.1 13.11 16 12.01 16C10.91 16 10.01 15.1 10.01 14H5V5H19V14Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 468 B

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18ZM12 16C14.2091 16 16 14.2091 16 12C16 9.79086 14.2091 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16Z"
fill="currentColor"/>
<path d="M18 5C17.4477 5 17 5.44772 17 6C17 6.55228 17.4477 7 18 7C18.5523 7 19 6.55228 19 6C19 5.44772 18.5523 5 18 5Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M1.65396 4.27606C1 5.55953 1 7.23969 1 10.6V13.4C1 16.7603 1 18.4405 1.65396 19.7239C2.2292 20.8529 3.14708 21.7708 4.27606 22.346C5.55953 23 7.23969 23 10.6 23H13.4C16.7603 23 18.4405 23 19.7239 22.346C20.8529 21.7708 21.7708 20.8529 22.346 19.7239C23 18.4405 23 16.7603 23 13.4V10.6C23 7.23969 23 5.55953 22.346 4.27606C21.7708 3.14708 20.8529 2.2292 19.7239 1.65396C18.4405 1 16.7603 1 13.4 1H10.6C7.23969 1 5.55953 1 4.27606 1.65396C3.14708 2.2292 2.2292 3.14708 1.65396 4.27606ZM13.4 3H10.6C8.88684 3 7.72225 3.00156 6.82208 3.0751C5.94524 3.14674 5.49684 3.27659 5.18404 3.43597C4.43139 3.81947 3.81947 4.43139 3.43597 5.18404C3.27659 5.49684 3.14674 5.94524 3.0751 6.82208C3.00156 7.72225 3 8.88684 3 10.6V13.4C3 15.1132 3.00156 16.2777 3.0751 17.1779C3.14674 18.0548 3.27659 18.5032 3.43597 18.816C3.81947 19.5686 4.43139 20.1805 5.18404 20.564C5.49684 20.7234 5.94524 20.8533 6.82208 20.9249C7.72225 20.9984 8.88684 21 10.6 21H13.4C15.1132 21 16.2777 20.9984 17.1779 20.9249C18.0548 20.8533 18.5032 20.7234 18.816 20.564C19.5686 20.1805 20.1805 19.5686 20.564 18.816C20.7234 18.5032 20.8533 18.0548 20.9249 17.1779C20.9984 16.2777 21 15.1132 21 13.4V10.6C21 8.88684 20.9984 7.72225 20.9249 6.82208C20.8533 5.94524 20.7234 5.49684 20.564 5.18404C20.1805 4.43139 19.5686 3.81947 18.816 3.43597C18.5032 3.27659 18.0548 3.14674 17.1779 3.0751C16.2777 3.00156 15.1132 3 13.4 3Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Lightbulb">
<path id="Vector"
d="M13.7501 18.125C13.7501 18.2908 13.6843 18.4498 13.567 18.567C13.4498 18.6842 13.2909 18.75 13.1251 18.75H6.8751C6.70934 18.75 6.55037 18.6842 6.43316 18.567C6.31595 18.4498 6.2501 18.2908 6.2501 18.125C6.2501 17.9593 6.31595 17.8003 6.43316 17.6831C6.55037 17.5659 6.70934 17.5 6.8751 17.5H13.1251C13.2909 17.5 13.4498 17.5659 13.567 17.6831C13.6843 17.8003 13.7501 17.9593 13.7501 18.125ZM16.8751 8.12504C16.8778 9.16695 16.6424 10.1957 16.1869 11.1328C15.7315 12.0699 15.0679 12.8905 14.247 13.5321C14.0935 13.6497 13.9689 13.8009 13.8828 13.9741C13.7967 14.1473 13.7513 14.3379 13.7501 14.5313V15C13.7501 15.3316 13.6184 15.6495 13.384 15.8839C13.1496 16.1183 12.8316 16.25 12.5001 16.25H7.5001C7.16858 16.25 6.85064 16.1183 6.61622 15.8839C6.3818 15.6495 6.2501 15.3316 6.2501 15V14.5313C6.24997 14.3402 6.20603 14.1517 6.12166 13.9802C6.03728 13.8088 5.91472 13.6589 5.76338 13.5422C4.94448 12.9045 4.28139 12.0887 3.8243 11.1568C3.36722 10.2249 3.12812 9.20128 3.1251 8.16332C3.10479 4.43989 6.11416 1.3391 9.83448 1.25004C10.7512 1.22795 11.663 1.38946 12.5163 1.72506C13.3696 2.06065 14.1472 2.56356 14.8033 3.20418C15.4593 3.84479 15.9806 4.61017 16.3364 5.45527C16.6922 6.30036 16.8754 7.2081 16.8751 8.12504ZM15.6251 8.12504C15.6253 7.37478 15.4754 6.63205 15.1843 5.94058C14.8932 5.24911 14.4666 4.62287 13.9298 4.09872C13.393 3.57458 12.7568 3.16312 12.0585 2.88856C11.3603 2.61401 10.6142 2.48191 9.86416 2.50004C6.81729 2.57192 4.3587 5.10864 4.3751 8.15551C4.37796 9.00441 4.57385 9.84153 4.94796 10.6036C5.32206 11.3656 5.86459 12.0325 6.53448 12.5539C6.8356 12.788 7.07918 13.0879 7.24655 13.4307C7.41392 13.7734 7.50065 14.1499 7.5001 14.5313V15H12.5001V14.5313C12.501 14.1488 12.5892 13.7715 12.758 13.4283C12.9269 13.0851 13.1719 12.785 13.4743 12.5508C14.1463 12.0257 14.6894 11.354 15.0621 10.5869C15.4349 9.81991 15.6274 8.97785 15.6251 8.12504ZM14.3665 7.39536C14.2044 6.49012 13.7689 5.65626 13.1186 5.00606C12.4682 4.35585 11.6343 3.92051 10.729 3.75864C10.6481 3.74499 10.5652 3.74742 10.4852 3.76579C10.4052 3.78416 10.3296 3.81811 10.2627 3.8657C10.1958 3.91329 10.139 3.97359 10.0954 4.04316C10.0518 4.11272 10.0223 4.19019 10.0087 4.27114C9.99505 4.35208 9.99748 4.43493 10.0159 4.51493C10.0342 4.59494 10.0682 4.67055 10.1158 4.73743C10.1634 4.80432 10.2237 4.86118 10.2932 4.90476C10.3628 4.94835 10.4402 4.9778 10.5212 4.99145C11.8157 5.20942 12.9142 6.30785 13.1337 7.60473C13.1584 7.75029 13.2339 7.8824 13.3467 7.97764C13.4595 8.07288 13.6025 8.1251 13.7501 8.12504C13.7854 8.12483 13.8207 8.12196 13.8556 8.11645C14.0189 8.08856 14.1645 7.99693 14.2604 7.8617C14.3562 7.72647 14.3944 7.55873 14.3665 7.39536Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,13 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8.68911 5.84996C9.19251 5.09341 9.06598 4.0823 8.39168 3.47312C7.71738 2.86394 6.69866 2.8404 5.99694 3.41779C5.29522 3.99517 5.12213 4.99935 5.59004 5.77835C6.05796 6.55735 7.02577 6.87624 7.86511 6.52796C8.20053 6.38801 8.48717 6.15215 8.68911 5.84996Z"
stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8.68911 16.1649C9.19251 15.4084 9.06598 14.3972 8.39168 13.7881C7.71738 13.1789 6.69866 13.1553 5.99694 13.7327C5.29522 14.3101 5.12213 15.3143 5.59004 16.0933C6.05796 16.8723 7.02577 17.1912 7.86511 16.8429C8.20053 16.703 8.48717 16.4671 8.68911 16.1649V16.1649Z"
stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.3111 11.0072C10.8077 10.2506 10.9342 9.23953 11.6085 8.63035C12.2828 8.02117 13.3015 7.99763 14.0032 8.57501C14.705 9.1524 14.8781 10.1566 14.4101 10.9356C13.9422 11.7146 12.9744 12.0335 12.1351 11.6852C11.7997 11.5452 11.513 11.3094 11.3111 11.0072V11.0072Z"
stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 4.22617C8.66863 4.22617 8.4 4.4948 8.4 4.82617C8.4 5.15754 8.66863 5.42617 9 5.42617V4.22617ZM17 5.42617C17.3314 5.42617 17.6 5.15754 17.6 4.82617C17.6 4.4948 17.3314 4.22617 17 4.22617V5.42617ZM5.327 5.42617C5.65837 5.42617 5.927 5.15754 5.927 4.82617C5.927 4.4948 5.65837 4.22617 5.327 4.22617V5.42617ZM3 4.22617C2.66863 4.22617 2.4 4.4948 2.4 4.82617C2.4 5.15754 2.66863 5.42617 3 5.42617V4.22617ZM9 14.5422C8.66863 14.5422 8.4 14.8108 8.4 15.1422C8.4 15.4735 8.66863 15.7422 9 15.7422V14.5422ZM17 15.7422C17.3314 15.7422 17.6 15.4735 17.6 15.1422C17.6 14.8108 17.3314 14.5422 17 14.5422V15.7422ZM5.327 15.7422C5.65837 15.7422 5.927 15.4735 5.927 15.1422C5.927 14.8108 5.65837 14.5422 5.327 14.5422V15.7422ZM3 14.5422C2.66863 14.5422 2.4 14.8108 2.4 15.1422C2.4 15.4735 2.66863 15.7422 3 15.7422V14.5422ZM11 10.5842C11.3314 10.5842 11.6 10.3155 11.6 9.98417C11.6 9.6528 11.3314 9.38417 11 9.38417V10.5842ZM3 9.38417C2.66863 9.38417 2.4 9.6528 2.4 9.98417C2.4 10.3155 2.66863 10.5842 3 10.5842V9.38417ZM14.6719 9.38438C14.3405 9.38438 14.0719 9.653 14.0719 9.98438C14.0719 10.3157 14.3405 10.5844 14.6719 10.5844V9.38438ZM16.9989 10.5844C17.3302 10.5844 17.5989 10.3157 17.5989 9.98438C17.5989 9.653 17.3302 9.38438 16.9989 9.38438V10.5844ZM9 5.42617H17V4.22617H9V5.42617ZM5.327 4.22617H3V5.42617H5.327V4.22617ZM9 15.7422H17V14.5422H9V15.7422ZM5.327 14.5422H3V15.7422H5.327V14.5422ZM11 9.38417H3V10.5842H11V9.38417ZM14.6719 10.5844H16.9989V9.38438H14.6719V10.5844Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 8C7.32843 8 8 7.32843 8 6.5C8 5.67157 7.32843 5 6.5 5C5.67157 5 5 5.67157 5 6.5C5 7.32843 5.67157 8 6.5 8Z"
fill="currentColor"/>
<path d="M5 10C5 9.44772 5.44772 9 6 9H7C7.55228 9 8 9.44771 8 10V18C8 18.5523 7.55228 19 7 19H6C5.44772 19 5 18.5523 5 18V10Z"
fill="currentColor"/>
<path d="M11 19H12C12.5523 19 13 18.5523 13 18V13.5C13 12 16 11 16 13V18.0004C16 18.5527 16.4477 19 17 19H18C18.5523 19 19 18.5523 19 18V12C19 10 17.5 9 15.5 9C13.5 9 13 10.5 13 10.5V10C13 9.44771 12.5523 9 12 9H11C10.4477 9 10 9.44772 10 10V18C10 18.5523 10.4477 19 11 19Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20 1C21.6569 1 23 2.34315 23 4V20C23 21.6569 21.6569 23 20 23H4C2.34315 23 1 21.6569 1 20V4C1 2.34315 2.34315 1 4 1H20ZM20 3C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3H20Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="MegaphoneSimple">
<path id="Vector"
d="M17.8547 6.7707L4.1 2.55195C3.91383 2.49765 3.71758 2.48742 3.52678 2.52205C3.33597 2.55668 3.15584 2.63523 3.00062 2.75148C2.84541 2.86774 2.71938 3.01852 2.63249 3.19189C2.5456 3.36526 2.50024 3.55647 2.5 3.75039V15.0004C2.5 15.3319 2.6317 15.6499 2.86612 15.8843C3.10054 16.1187 3.41848 16.2504 3.75 16.2504C3.86953 16.2504 3.98845 16.2333 4.10313 16.1996L10.625 14.198V15.0004C10.625 15.3319 10.7567 15.6499 10.9911 15.8843C11.2255 16.1187 11.5435 16.2504 11.875 16.2504H14.375C14.7065 16.2504 15.0245 16.1187 15.2589 15.8843C15.4933 15.6499 15.625 15.3319 15.625 15.0004V12.6645L17.8547 11.9809C18.1127 11.9033 18.3391 11.7449 18.5003 11.529C18.6615 11.3131 18.749 11.0511 18.75 10.7816V7.96914C18.7488 7.69982 18.6612 7.438 18.5 7.22224C18.3388 7.00648 18.1126 6.8482 17.8547 6.7707ZM10.625 12.891L3.75 15.0004V3.75039L10.625 5.85977V12.891ZM14.375 15.0004H11.875V13.8145L14.375 13.0473V15.0004ZM17.5 10.7816H17.4914L11.875 12.5066V6.24414L17.4914 7.96289H17.5V10.7754V10.7816Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Monitor">
<path id="Vector"
d="M16.25 3.125H3.75C3.25272 3.125 2.77581 3.32254 2.42417 3.67417C2.07254 4.02581 1.875 4.50272 1.875 5V13.75C1.875 14.2473 2.07254 14.7242 2.42417 15.0758C2.77581 15.4275 3.25272 15.625 3.75 15.625H16.25C16.7473 15.625 17.2242 15.4275 17.5758 15.0758C17.9275 14.7242 18.125 14.2473 18.125 13.75V5C18.125 4.50272 17.9275 4.02581 17.5758 3.67417C17.2242 3.32254 16.7473 3.125 16.25 3.125ZM16.875 13.75C16.875 13.9158 16.8092 14.0747 16.6919 14.1919C16.5747 14.3092 16.4158 14.375 16.25 14.375H3.75C3.58424 14.375 3.42527 14.3092 3.30806 14.1919C3.19085 14.0747 3.125 13.9158 3.125 13.75V5C3.125 4.83424 3.19085 4.67527 3.30806 4.55806C3.42527 4.44085 3.58424 4.375 3.75 4.375H16.25C16.4158 4.375 16.5747 4.44085 16.6919 4.55806C16.8092 4.67527 16.875 4.83424 16.875 5V13.75ZM13.125 17.5C13.125 17.6658 13.0592 17.8247 12.9419 17.9419C12.8247 18.0592 12.6658 18.125 12.5 18.125H7.5C7.33424 18.125 7.17527 18.0592 7.05806 17.9419C6.94085 17.8247 6.875 17.6658 6.875 17.5C6.875 17.3342 6.94085 17.1753 7.05806 17.0581C7.17527 16.9408 7.33424 16.875 7.5 16.875H12.5C12.6658 16.875 12.8247 16.9408 12.9419 17.0581C13.0592 17.1753 13.125 17.3342 13.125 17.5Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Notepad">
<path id="Vector"
d="M13.125 10C13.125 10.1658 13.0592 10.3247 12.9419 10.4419C12.8247 10.5592 12.6658 10.625 12.5 10.625H7.5C7.33424 10.625 7.17527 10.5592 7.05806 10.4419C6.94085 10.3247 6.875 10.1658 6.875 10C6.875 9.83424 6.94085 9.67527 7.05806 9.55806C7.17527 9.44085 7.33424 9.375 7.5 9.375H12.5C12.6658 9.375 12.8247 9.44085 12.9419 9.55806C13.0592 9.67527 13.125 9.83424 13.125 10ZM12.5 11.875H7.5C7.33424 11.875 7.17527 11.9408 7.05806 12.0581C6.94085 12.1753 6.875 12.3342 6.875 12.5C6.875 12.6658 6.94085 12.8247 7.05806 12.9419C7.17527 13.0592 7.33424 13.125 7.5 13.125H12.5C12.6658 13.125 12.8247 13.0592 12.9419 12.9419C13.0592 12.8247 13.125 12.6658 13.125 12.5C13.125 12.3342 13.0592 12.1753 12.9419 12.0581C12.8247 11.9408 12.6658 11.875 12.5 11.875ZM16.875 3.125V15.625C16.875 16.288 16.6116 16.9239 16.1428 17.3928C15.6739 17.8616 15.038 18.125 14.375 18.125H5.625C4.96196 18.125 4.32607 17.8616 3.85723 17.3928C3.38839 16.9239 3.125 16.288 3.125 15.625V3.125C3.125 2.95924 3.19085 2.80027 3.30806 2.68306C3.42527 2.56585 3.58424 2.5 3.75 2.5H5.625V1.875C5.625 1.70924 5.69085 1.55027 5.80806 1.43306C5.92527 1.31585 6.08424 1.25 6.25 1.25C6.41576 1.25 6.57473 1.31585 6.69194 1.43306C6.80915 1.55027 6.875 1.70924 6.875 1.875V2.5H9.375V1.875C9.375 1.70924 9.44085 1.55027 9.55806 1.43306C9.67527 1.31585 9.83424 1.25 10 1.25C10.1658 1.25 10.3247 1.31585 10.4419 1.43306C10.5592 1.55027 10.625 1.70924 10.625 1.875V2.5H13.125V1.875C13.125 1.70924 13.1908 1.55027 13.3081 1.43306C13.4253 1.31585 13.5842 1.25 13.75 1.25C13.9158 1.25 14.0747 1.31585 14.1919 1.43306C14.3092 1.55027 14.375 1.70924 14.375 1.875V2.5H16.25C16.4158 2.5 16.5747 2.56585 16.6919 2.68306C16.8092 2.80027 16.875 2.95924 16.875 3.125ZM15.625 3.75H14.375V4.375C14.375 4.54076 14.3092 4.69973 14.1919 4.81694C14.0747 4.93415 13.9158 5 13.75 5C13.5842 5 13.4253 4.93415 13.3081 4.81694C13.1908 4.69973 13.125 4.54076 13.125 4.375V3.75H10.625V4.375C10.625 4.54076 10.5592 4.69973 10.4419 4.81694C10.3247 4.93415 10.1658 5 10 5C9.83424 5 9.67527 4.93415 9.55806 4.81694C9.44085 4.69973 9.375 4.54076 9.375 4.375V3.75H6.875V4.375C6.875 4.54076 6.80915 4.69973 6.69194 4.81694C6.57473 4.93415 6.41576 5 6.25 5C6.08424 5 5.92527 4.93415 5.80806 4.81694C5.69085 4.69973 5.625 4.54076 5.625 4.375V3.75H4.375V15.625C4.375 15.9565 4.5067 16.2745 4.74112 16.5089C4.97554 16.7433 5.29348 16.875 5.625 16.875H14.375C14.7065 16.875 15.0245 16.7433 15.2589 16.5089C15.4933 16.2745 15.625 15.9565 15.625 15.625V3.75Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Star">
<path id="Vector"
d="M12.5994 10.4965C13.2891 9.9569 13.7924 9.21691 14.0395 8.37951C14.2866 7.5421 14.2651 6.64893 13.978 5.82425C13.6909 4.99956 13.1525 4.28438 12.4376 3.7782C11.7228 3.27201 10.8671 3 9.98957 3C9.11204 3 8.25633 3.27201 7.54149 3.7782C6.82665 4.28438 6.28822 4.99956 6.00112 5.82425C5.71401 6.64893 5.69251 7.5421 5.9396 8.37951C6.18669 9.21691 6.69008 9.9569 7.37974 10.4965C6.19799 10.9674 5.16687 11.7483 4.39631 12.7562C3.62575 13.764 3.14463 14.9609 3.00424 16.2192C2.99408 16.3111 3.00221 16.4041 3.02818 16.4928C3.05414 16.5816 3.09743 16.6643 3.15556 16.7365C3.27298 16.8821 3.44375 16.9754 3.63032 16.9958C3.81689 17.0162 4.00397 16.9621 4.1504 16.8453C4.29684 16.7285 4.39063 16.5587 4.41116 16.3731C4.56562 15.0056 5.22132 13.7425 6.25296 12.8253C7.2846 11.9082 8.61987 11.4011 10.0036 11.4011C11.3874 11.4011 12.7227 11.9082 13.7543 12.8253C14.786 13.7425 15.4416 15.0056 15.5961 16.3731C15.6152 16.545 15.6977 16.7038 15.8276 16.8188C15.9575 16.9338 16.1257 16.9968 16.2996 16.9958H16.377C16.5614 16.9747 16.7299 16.8819 16.8458 16.7378C16.9618 16.5937 17.0158 16.4098 16.996 16.2262C16.8549 14.9643 16.3712 13.7643 15.5967 12.755C14.8222 11.7456 13.7861 10.9649 12.5994 10.4965ZM9.98957 9.99981C9.43304 9.99981 8.88902 9.83569 8.42629 9.5282C7.96356 9.22071 7.6029 8.78366 7.38993 8.27232C7.17696 7.76098 7.12123 7.19832 7.22981 6.65549C7.33838 6.11265 7.60637 5.61403 7.99989 5.22267C8.39341 4.83131 8.89479 4.56478 9.44062 4.45681C9.98645 4.34883 10.5522 4.40425 11.0664 4.61605C11.5805 4.82786 12.02 5.18653 12.3292 5.64672C12.6384 6.10692 12.8034 6.64796 12.8034 7.20143C12.8034 7.9436 12.5069 8.65538 11.9792 9.18018C11.4515 9.70498 10.7358 9.99981 9.98957 9.99981Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,21 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Sparkle">
<g id="Vector" opacity="0.9">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8.03755 3.91001C8.24703 3.76431 8.49609 3.68622 8.75126 3.68622C9.00643 3.68622 9.25549 3.76431 9.46497 3.91001C9.67446 4.0557 9.83434 4.26202 9.92313 4.50124L9.92364 4.50261L11.4236 8.57292L11.4255 8.57578L11.4283 8.57758L15.5 10.0781C15.7392 10.1669 15.9455 10.3268 16.0912 10.5363C16.2369 10.7458 16.315 10.9948 16.315 11.25C16.315 11.5051 16.2369 11.7542 16.0912 11.9637C15.9455 12.1732 15.7392 12.3331 15.5 12.4219L15.4986 12.4224L11.4283 13.9224L11.4255 13.9242L11.4237 13.927L9.92364 17.9973L9.92313 17.9987C9.83434 18.2379 9.67446 18.4443 9.46497 18.59C9.25549 18.7356 9.00643 18.8137 8.75126 18.8137C8.49609 18.8137 8.24703 18.7356 8.03755 18.59C7.82806 18.4443 7.66818 18.2379 7.57938 17.9987L7.57888 17.9973L6.07888 13.927L6.07706 13.9242L6.07578 13.9231L6.07423 13.9224L2.00389 12.4224L2.00252 12.4219C1.7633 12.3331 1.55698 12.1732 1.41129 11.9637C1.26559 11.7542 1.1875 11.5051 1.1875 11.25C1.1875 10.9948 1.26559 10.7458 1.41129 10.5363C1.55698 10.3268 1.7633 10.1669 2.00252 10.0781L2.00389 10.0776L6.0742 8.5776L6.07706 8.57578L6.07887 8.57295L7.57888 4.50261L7.57938 4.50124C7.66818 4.26202 7.82806 4.0557 8.03755 3.91001ZM10.2508 13.4948C10.3137 13.324 10.4129 13.169 10.5416 13.0403C10.6703 12.9116 10.8253 12.8124 10.9961 12.7495L15.065 11.25L10.9961 9.7505C10.8254 9.68758 10.6703 9.58834 10.5416 9.45966C10.4129 9.33098 10.3137 9.17592 10.2508 9.00516L8.75126 4.93622L7.25178 9.00513C7.18886 9.17588 7.08962 9.33098 6.96094 9.45966C6.83226 9.58834 6.67719 9.68756 6.50644 9.75049L2.4375 11.25L6.50641 12.7495C6.67717 12.8124 6.83226 12.9116 6.96094 13.0403C7.08962 13.169 7.18884 13.324 7.25177 13.4948L8.75126 17.5637L10.2508 13.4948Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M13.7513 0.625C14.0965 0.625 14.3763 0.904822 14.3763 1.25V5C14.3763 5.34518 14.0965 5.625 13.7513 5.625C13.4061 5.625 13.1263 5.34518 13.1263 5V1.25C13.1263 0.904822 13.4061 0.625 13.7513 0.625Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.2513 3.125C11.2513 2.77982 11.5311 2.5 11.8763 2.5H15.6263C15.9715 2.5 16.2513 2.77982 16.2513 3.125C16.2513 3.47018 15.9715 3.75 15.6263 3.75H11.8763C11.5311 3.75 11.2513 3.47018 11.2513 3.125Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M17.5013 5C17.8465 5 18.1263 5.27982 18.1263 5.625V8.125C18.1263 8.47018 17.8465 8.75 17.5013 8.75C17.1561 8.75 16.8763 8.47018 16.8763 8.125V5.625C16.8763 5.27982 17.1561 5 17.5013 5Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M15.6263 6.875C15.6263 6.52982 15.9061 6.25 16.2513 6.25H18.7513C19.0965 6.25 19.3763 6.52982 19.3763 6.875C19.3763 7.22018 19.0965 7.5 18.7513 7.5H16.2513C15.9061 7.5 15.6263 7.22018 15.6263 6.875Z"
fill="currentColor"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.8889 3H4.11111C3.49746 3 3 3.49746 3 4.11111V11.8889C3 12.5025 3.49746 13 4.11111 13H11.8889C12.5025 13 13 12.5025 13 11.8889V4.11111C13 3.49746 12.5025 3 11.8889 3Z"
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 6.33203H13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 12.9987V6.33203" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 570 B

View File

@ -0,0 +1,5 @@
<svg fill="currentColor" width="800px" height="800px" viewBox="0 0 32 32" version="1.1"
xmlns="http://www.w3.org/2000/svg">
<title>tiktok</title>
<path d="M16.656 1.029c1.637-0.025 3.262-0.012 4.886-0.025 0.054 2.031 0.878 3.859 2.189 5.213l-0.002-0.002c1.411 1.271 3.247 2.095 5.271 2.235l0.028 0.002v5.036c-1.912-0.048-3.71-0.489-5.331-1.247l0.082 0.034c-0.784-0.377-1.447-0.764-2.077-1.196l0.052 0.034c-0.012 3.649 0.012 7.298-0.025 10.934-0.103 1.853-0.719 3.543-1.707 4.954l0.020-0.031c-1.652 2.366-4.328 3.919-7.371 4.011l-0.014 0c-0.123 0.006-0.268 0.009-0.414 0.009-1.73 0-3.347-0.482-4.725-1.319l0.040 0.023c-2.508-1.509-4.238-4.091-4.558-7.094l-0.004-0.041c-0.025-0.625-0.037-1.25-0.012-1.862 0.49-4.779 4.494-8.476 9.361-8.476 0.547 0 1.083 0.047 1.604 0.136l-0.056-0.008c0.025 1.849-0.050 3.699-0.050 5.548-0.423-0.153-0.911-0.242-1.42-0.242-1.868 0-3.457 1.194-4.045 2.861l-0.009 0.030c-0.133 0.427-0.21 0.918-0.21 1.426 0 0.206 0.013 0.41 0.037 0.61l-0.002-0.024c0.332 2.046 2.086 3.59 4.201 3.59 0.061 0 0.121-0.001 0.181-0.004l-0.009 0c1.463-0.044 2.733-0.831 3.451-1.994l0.010-0.018c0.267-0.372 0.45-0.822 0.511-1.311l0.001-0.014c0.125-2.237 0.075-4.461 0.087-6.698 0.012-5.036-0.012-10.060 0.025-15.083z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,5 +1,5 @@
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.6">
<g opacity="1">
<path d="M16.5 4.48535C14.0025 4.23785 11.49 4.11035 8.985 4.11035C7.5 4.11035 6.015 4.18535 4.53 4.33535L3 4.48535"
stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.125 3.7275L7.29 2.745C7.41 2.0325 7.5 1.5 8.7675 1.5H10.7325C12 1.5 12.0975 2.0625 12.21 2.7525L12.375 3.7275"

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M1.8247 2.61328L9.63356 13.0859L1.77539 21.6005H3.54395L10.4238 14.1458L15.9825 21.6005H22.0009L13.7527 10.5389L21.067 2.61328H19.2985L12.9625 9.47893L7.84317 2.61328H1.8247ZM4.4255 3.91992H7.1904L19.3997 20.2937H16.6348L4.4255 3.91992Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M11.99 2C6.47 2 2 6.48 2 12C2 17.52 6.47 22 11.99 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 11.99 2ZM18.92 8H15.97C15.65 6.75 15.19 5.55 14.59 4.44C16.43 5.07 17.96 6.35 18.92 8ZM12 4.04C12.83 5.24 13.48 6.57 13.91 8H10.09C10.52 6.57 11.17 5.24 12 4.04ZM4.26 14C4.1 13.36 4 12.69 4 12C4 11.31 4.1 10.64 4.26 10H7.64C7.56 10.66 7.5 11.32 7.5 12C7.5 12.68 7.56 13.34 7.64 14H4.26ZM5.08 16H8.03C8.35 17.25 8.81 18.45 9.41 19.56C7.57 18.93 6.04 17.66 5.08 16ZM8.03 8H5.08C6.04 6.34 7.57 5.07 9.41 4.44C8.81 5.55 8.35 6.75 8.03 8ZM12 19.96C11.17 18.76 10.52 17.43 10.09 16H13.91C13.48 17.43 12.83 18.76 12 19.96ZM14.34 14H9.66C9.57 13.34 9.5 12.68 9.5 12C9.5 11.32 9.57 10.65 9.66 10H14.34C14.43 10.65 14.5 11.32 14.5 12C14.5 12.68 14.43 13.34 14.34 14ZM14.59 19.56C15.19 18.45 15.65 17.25 15.97 16H18.92C17.96 17.65 16.43 18.93 14.59 19.56ZM16.36 14C16.44 13.34 16.5 12.68 16.5 12C16.5 11.32 16.44 10.66 16.36 10H19.74C19.9 10.64 20 11.31 20 12C20 12.69 19.9 13.36 19.74 14H16.36Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M9.49614 7.13176C9.18664 6.9549 8.80639 6.95617 8.49807 7.13509C8.18976 7.31401 8 7.64353 8 8V16C8 16.3565 8.18976 16.686 8.49807 16.8649C8.80639 17.0438 9.18664 17.0451 9.49614 16.8682L16.4961 12.8682C16.8077 12.6902 17 12.3589 17 12C17 11.6411 16.8077 11.3098 16.4961 11.1318L9.49614 7.13176ZM13.9844 12L10 14.2768V9.72318L13.9844 12Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 12C0 8.25027 0 6.3754 0.954915 5.06107C1.26331 4.6366 1.6366 4.26331 2.06107 3.95491C3.3754 3 5.25027 3 9 3H15C18.7497 3 20.6246 3 21.9389 3.95491C22.3634 4.26331 22.7367 4.6366 23.0451 5.06107C24 6.3754 24 8.25027 24 12C24 15.7497 24 17.6246 23.0451 18.9389C22.7367 19.3634 22.3634 19.7367 21.9389 20.0451C20.6246 21 18.7497 21 15 21H9C5.25027 21 3.3754 21 2.06107 20.0451C1.6366 19.7367 1.26331 19.3634 0.954915 18.9389C0 17.6246 0 15.7497 0 12ZM9 5H15C16.9194 5 18.1983 5.00275 19.1673 5.10773C20.0989 5.20866 20.504 5.38448 20.7634 5.57295C21.018 5.75799 21.242 5.98196 21.4271 6.23664C21.6155 6.49605 21.7913 6.90113 21.8923 7.83269C21.9973 8.80167 22 10.0806 22 12C22 13.9194 21.9973 15.1983 21.8923 16.1673C21.7913 17.0989 21.6155 17.504 21.4271 17.7634C21.242 18.018 21.018 18.242 20.7634 18.4271C20.504 18.6155 20.0989 18.7913 19.1673 18.8923C18.1983 18.9973 16.9194 19 15 19H9C7.08058 19 5.80167 18.9973 4.83269 18.8923C3.90113 18.7913 3.49605 18.6155 3.23664 18.4271C2.98196 18.242 2.75799 18.018 2.57295 17.7634C2.38448 17.504 2.20866 17.0989 2.10773 16.1673C2.00275 15.1983 2 13.9194 2 12C2 10.0806 2.00275 8.80167 2.10773 7.83269C2.20866 6.90113 2.38448 6.49605 2.57295 6.23664C2.75799 5.98196 2.98196 5.75799 3.23664 5.57295C3.49605 5.38448 3.90113 5.20866 4.83269 5.10773C5.80167 5.00275 7.08058 5 9 5Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,100 @@
import React, { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
ReactComponent as Inbox,
} from '@/assets/inbox.svg';
interface FileDropzoneProps {
onChange?: (files: File[]) => void;
accept?: string;
multiple?: boolean;
}
function FileDropzone ({
onChange,
accept,
multiple,
}: FileDropzoneProps) {
const { t } = useTranslation();
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFiles = (files: FileList) => {
const fileArray = Array.from(files);
if (onChange) {
if (!multiple && fileArray.length > 1) {
onChange(fileArray.slice(0, 1));
} else {
onChange(fileArray);
}
}
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setDragging(false);
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
handleFiles(event.dataTransfer.files);
event.dataTransfer.clearData();
}
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setDragging(false);
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
handleFiles(event.target.files);
event.target.value = '';
}
};
return (
<div
className={'w-full cursor-pointer hover:border-fill-active hover:border-2 hover:bg-bg-body h-[160px] rounded-xl border border-dashed border-line-border flex flex-col bg-bg-base'}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={handleClick}
style={{
borderColor: dragging ? 'var(--fill-active)' : undefined,
backgroundColor: dragging ? 'var(--fill-active)' : undefined,
}}
>
<div className={'flex flex-col items-center justify-center gap-4 h-full'}>
<Inbox className={'w-12 h-12 text-fill-default'} />
<div className={'text-base text-text-title'}>
{t('fileDropzone.dropFile')}
</div>
</div>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept={accept}
multiple={multiple}
onChange={handleFileChange}
/>
</div>
);
}
export default FileDropzone;

View File

@ -18,7 +18,7 @@ export interface NormalModalProps extends DialogProps {
okLoading?: boolean;
}
export function NormalModal({
export function NormalModal ({
okText,
title,
cancelText,
@ -35,7 +35,6 @@ export function NormalModal({
const { t } = useTranslation();
const modalOkText = okText || t('button.ok');
const modalCancelText = cancelText || t('button.cancel');
const buttonColor = danger ? 'var(--function-error)' : undefined;
return (
<Dialog
@ -57,14 +56,20 @@ export function NormalModal({
</div>
<div className={'flex-1'}>{children}</div>
<div className={'flex w-full justify-end gap-4'}>
<Button color={'inherit'} variant={'outlined'} onClick={onCancel} {...cancelButtonProps}>
<div className={'flex w-full justify-end gap-3'}>
<Button color={'inherit'} variant={'outlined'} onClick={() => {
if (onCancel) {
onCancel();
} else {
onClose?.();
}
}} {...cancelButtonProps}>
{modalCancelText}
</Button>
<Button
color={'primary'}
color={danger ? 'error' : 'primary'}
variant={'contained'}
style={{ backgroundColor: buttonColor }}
onClick={() => {
if (okLoading) return;
onOk?.();

View File

@ -26,7 +26,7 @@ export const RichTooltip = ({ placement = 'top', open, onClose, content, childre
anchorEl={childNode}
placement={placement}
transition
style={{ zIndex: 1200 }}
style={{ zIndex: 1500 }}
modifiers={[
{
name: 'flip',
@ -48,7 +48,7 @@ export const RichTooltip = ({ placement = 'top', open, onClose, content, childre
>
<Paper className={'bg-transparent shadow-none'}>
<ClickAwayListener onClickAway={onClose}>
<Paper className={'m-2 rounded-md border border-line-divider bg-bg-body'}>
<Paper className={'m-2 rounded-md border border-line-divider bg-bg-body overflow-hidden'}>
<Box>{content}</Box>
</Paper>
</ClickAwayListener>

View File

@ -2,6 +2,7 @@ import { AUTH_CALLBACK_PATH } from '@/application/session/sign_in';
import NotFound from '@/components/error/NotFound';
import LoginAuth from '@/components/login/LoginAuth';
import AfterPaymentPage from '@/pages/AfterPaymentPage';
import AsTemplatePage from '@/pages/AsTemplatePage';
import LoginPage from '@/pages/LoginPage';
import PublishPage from '@/pages/PublishPage';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
@ -16,6 +17,7 @@ const AppMain = withAppWrapper(() => {
<Route path={AUTH_CALLBACK_PATH} element={<LoginAuth />} />
<Route path='/404' element={<NotFound />} />
<Route path='/after-payment' element={<AfterPaymentPage />} />
<Route path='/as-template' element={<AsTemplatePage />} />
<Route path='*' element={<NotFound />} />
</Routes>
);

View File

@ -1,38 +1,18 @@
import { clearData } from '@/application/db';
import { getService } from '@/application/services';
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { EventType, on } from '@/application/session';
import { isTokenValid } from '@/application/session/token';
import { User } from '@/application/types';
import { InfoSnackbarProps } from '@/components/_shared/notify';
import { AFConfigContext, defaultConfig } from '@/components/app/app.hooks';
import { useAppLanguage } from '@/components/app/useAppLanguage';
import { LoginModal } from '@/components/login';
import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
import { useSnackbar } from 'notistack';
import React, { createContext, useCallback, useEffect, useState } from 'react';
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { getService } from '@/application/services';
import { InfoSnackbarProps } from '@/components/_shared/notify';
import { User } from '@/application/types';
import React, { useCallback, useEffect, useState } from 'react';
const baseURL = import.meta.env.AF_BASE_URL || 'https://test.appflowy.cloud';
const gotrueURL = import.meta.env.AF_GOTRUE_URL || 'https://test.appflowy.cloud/gotrue';
const wsURL = import.meta.env.AF_WS_URL || 'wss://test.appflowy.cloud/ws/v1';
const defaultConfig: AFServiceConfig = {
cloudConfig: {
baseURL,
gotrueURL,
wsURL,
},
};
export const AFConfigContext = createContext<
| {
service: AFService | undefined;
isAuthenticated: boolean;
currentUser?: User;
openLoginModal: (redirectTo?: string) => void;
}
| undefined
>(undefined);
function AppConfig({ children }: { children: React.ReactNode }) {
function AppConfig ({ children }: { children: React.ReactNode }) {
const [appConfig] = useState<AFServiceConfig>(defaultConfig);
const [service, setService] = useState<AFService>();
const [isAuthenticated, setIsAuthenticated] = React.useState<boolean>(isTokenValid());
@ -123,13 +103,18 @@ function AppConfig({ children }: { children: React.ReactNode }) {
useEffect(() => {
const handleClearData = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'r' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
switch (true) {
case createHotkey(HOT_KEY_NAME.CLEAR_CACHE)(e):
e.stopPropagation();
e.preventDefault();
void clearData().then(() => {
window.location.reload();
});
break;
default:
break;
}
};
window.addEventListener('keydown', handleClearData);

View File

@ -7,7 +7,7 @@ import '@/i18n/config';
import 'src/styles/tailwind.css';
import 'src/styles/template.css';
function AppTheme({ children }: { children: React.ReactNode }) {
function AppTheme ({ children }: { children: React.ReactNode; }) {
const { isDark, setIsDark } = useAppThemeMode();
const theme = useMemo(
@ -47,6 +47,7 @@ function AppTheme({ children }: { children: React.ReactNode }) {
},
},
},
MuiButton: {
styleOverrides: {
text: {
@ -58,9 +59,12 @@ function AppTheme({ children }: { children: React.ReactNode }) {
contained: {
color: 'var(--content-on-fill)',
boxShadow: 'none',
'&.MuiButton-containedPrimary': {
'&:hover': {
backgroundColor: 'var(--content-blue-600)',
},
},
borderRadius: '8px',
'&.Mui-disabled': {
backgroundColor: 'var(--content-blue-400)',
@ -74,6 +78,7 @@ function AppTheme({ children }: { children: React.ReactNode }) {
},
borderRadius: '8px',
},
},
},
@ -88,12 +93,16 @@ function AppTheme({ children }: { children: React.ReactNode }) {
backgroundColor: 'var(--fill-list-hover)',
},
},
'&.MuiMenuItem-root': {
borderRadius: '8px',
},
borderRadius: '4px',
padding: '2px',
boxShadow: 'none !important',
},
},
},
MuiPaper: {
styleOverrides: {
@ -191,7 +200,7 @@ function AppTheme({ children }: { children: React.ReactNode }) {
},
},
}),
[isDark]
[isDark],
);
return (

View File

@ -0,0 +1,45 @@
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { User } from '@/application/types';
import { createContext, useContext } from 'react';
const baseURL = import.meta.env.AF_BASE_URL || 'https://test.appflowy.cloud';
const gotrueURL = import.meta.env.AF_GOTRUE_URL || 'https://test.appflowy.cloud/gotrue';
const wsURL = import.meta.env.AF_WS_URL || 'wss://test.appflowy.cloud/ws/v1';
export const defaultConfig: AFServiceConfig = {
cloudConfig: {
baseURL,
gotrueURL,
wsURL,
},
};
export const AFConfigContext = createContext<
| {
service: AFService | undefined;
isAuthenticated: boolean;
currentUser?: User;
openLoginModal: (redirectTo?: string) => void;
}
| undefined
>(undefined);
export function useCurrentUser () {
const context = useContext(AFConfigContext);
if (!context) {
throw new Error('useCurrentUser must be used within a AFConfigContext');
}
return context.currentUser;
}
export function useService () {
const context = useContext(AFConfigContext);
if (!context) {
throw new Error('useService must be used within a AFConfigContext');
}
return context.service;
}

View File

@ -4,19 +4,30 @@ export const ThemeModeContext = createContext<
| {
isDark: boolean;
setDark: (isDark: boolean) => void;
}
}
| undefined
>(undefined);
export function useAppThemeMode() {
export function useAppThemeMode () {
const fixedTheme = window.location.search.includes('theme') ? new URLSearchParams(window.location.search).get('theme') : null;
const [isDark, setIsDark] = useState<boolean>(() => {
if (fixedTheme === 'light') {
return false;
}
if (fixedTheme === 'dark') {
return true;
}
const darkMode = localStorage.getItem('dark-mode');
return darkMode === 'true';
});
useEffect(() => {
function detectColorScheme() {
if (fixedTheme) return;
function detectColorScheme () {
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
setIsDark(darkModeMediaQuery.matches);
@ -30,7 +41,7 @@ export function useAppThemeMode() {
return () => {
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', detectColorScheme);
};
}, []);
}, [fixedTheme]);
useEffect(() => {
document.documentElement.setAttribute('data-dark-mode', isDark ? 'true' : 'false');

View File

@ -0,0 +1,191 @@
import { UploadTemplatePayload } from '@/application/template.type';
import { notify } from '@/components/_shared/notify';
import { AFScroller } from '@/components/_shared/scroller';
import { useService } from '@/components/app/app.hooks';
import AsTemplateForm, { AsTemplateFormValue } from '@/components/as-template/AsTemplateForm';
import Categories from '@/components/as-template/category/Categories';
import Creator from '@/components/as-template/creator/Creator';
import DeleteTemplate from '@/components/as-template/DeleteTemplate';
import { useLoadTemplate } from '@/components/as-template/hooks';
import { Button, CircularProgress, InputLabel, Paper, Switch } from '@mui/material';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
import { ReactComponent as DeleteIcon } from '@/assets/trash.svg';
import './template.scss';
import { useNavigate } from 'react-router-dom';
function AsTemplate ({
viewName,
viewUrl,
viewId,
}: {
viewName: string;
viewUrl: string;
viewId: string;
}) {
const [selectedCategoryIds, setSelectedCategoryIds] = useState<string[]>([]);
const [selectedCreatorId, setSelectedCreatorId] = useState<string | undefined>(undefined);
const { t } = useTranslation();
const [isNewTemplate, setIsNewTemplate] = React.useState(false);
const [isFeatured, setIsFeatured] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const service = useService();
const {
template,
loadTemplate,
loading,
} = useLoadTemplate(viewId);
const navigate = useNavigate();
const handleSubmit = useCallback(async (data: AsTemplateFormValue) => {
if (!service || !selectedCreatorId || selectedCategoryIds.length === 0) return;
const formData: UploadTemplatePayload = {
...data,
view_id: viewId,
category_ids: selectedCategoryIds,
creator_id: selectedCreatorId,
is_new_template: isNewTemplate,
is_featured: isFeatured,
view_url: viewUrl,
};
console.log('formData', formData);
try {
if (template) {
await service?.updateTemplate(template.view_id, formData);
} else {
await service?.createTemplate(formData);
await loadTemplate();
}
notify.info({
type: 'success',
title: t('template.uploadSuccess'),
message: t('template.uploadSuccessDescription'),
okText: t('template.viewTemplate'),
onOk: () => {
const url = import.meta.env.AF_BASE_URL?.includes('test') ? 'https://test.appflowy.io' : 'https://appflowy.io';
window.open(`${url}/template-center/${selectedCategoryIds[0]}/${viewId}`, '_blank');
},
});
navigate(-1);
} catch (error) {
// eslint-disable-next-line
// @ts-ignore
notify.error(error.toString());
}
}, [service, selectedCreatorId, selectedCategoryIds, viewId, isNewTemplate, isFeatured, viewUrl, template, t, navigate, loadTemplate]);
const submitRef = React.useRef<HTMLInputElement>(null);
useEffect(() => {
void loadTemplate();
}, [loadTemplate]);
useEffect(() => {
if (!template) return;
setSelectedCategoryIds(template.categories.map((category) => category.id));
setSelectedCreatorId(template.creator.id);
setIsNewTemplate(template.is_new_template);
setIsFeatured(template.is_featured);
}, [template]);
const defaultValue = useMemo(() => {
if (!template) return {
name: viewName,
description: '',
about: '',
related_view_ids: [],
};
return {
name: template.name,
description: template.description,
about: template.about,
related_view_ids: template.related_templates?.map((related) => related.view_id) || [],
};
}, [template, viewName]);
return (
<div className={'flex flex-col gap-4 w-full h-full overflow-hidden'}>
<div className={'flex items-center justify-between'}>
<Button
onClick={() => {
navigate(-1);
}}
variant={'outlined'}
color={'inherit'}
endIcon={<CloseIcon className={'w-4 h-4'} />}
>
{t('button.cancel')}
</Button>
<div className={'flex items-center gap-2'}>
{template && <Button
startIcon={<DeleteIcon />}
color={'error'}
onClick={() => {
setDeleteModalOpen(true);
}}
variant={'text'}
>
{t('template.deleteTemplate')}
</Button>}
<Button onClick={() => {
submitRef.current?.click();
}} variant={'contained'} color={'primary'}
>
{t('template.asTemplate')}
</Button>
</div>
</div>
<div className={'flex-1 flex gap-20 overflow-hidden'}>
<Paper className={'w-full h-full flex-1 flex justify-center overflow-hidden'}>
<AFScroller className={'w-full h-full flex justify-center'} overflowXHidden>
{loading ?
<CircularProgress /> :
<AsTemplateForm
defaultValues={defaultValue} viewUrl={viewUrl}
onSubmit={handleSubmit}
ref={submitRef}
defaultRelatedTemplates={template?.related_templates}
/>
}
</AFScroller>
</Paper>
<div className={'w-[25%] flex flex-col gap-4'}>
<Categories value={selectedCategoryIds} onChange={setSelectedCategoryIds} />
<Creator value={selectedCreatorId} onChange={setSelectedCreatorId} />
<div className={'flex gap-2 items-center'}>
<InputLabel>{t('template.isNewTemplate')}</InputLabel>
<Switch
checked={isNewTemplate}
onChange={() => setIsNewTemplate(!isNewTemplate)}
/>
</div>
<div className={'flex gap-2 items-center'}>
<InputLabel>{t('template.featured')}</InputLabel>
<Switch
checked={isFeatured}
onChange={() => setIsFeatured(!isFeatured)}
/>
</div>
</div>
</div>
{deleteModalOpen && <DeleteTemplate id={viewId} onDeleted={() => {
navigate(-1);
}} open={deleteModalOpen} onClose={() => setDeleteModalOpen(false)}
/>}
</div>
);
}
export default AsTemplate;

View File

@ -0,0 +1,40 @@
import { useCurrentUser } from '@/components/app/app.hooks';
import { useViewMeta } from '@/components/publish/useViewMeta';
import { Button, Divider } from '@mui/material';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ReactComponent as TemplateIcon } from '@/assets/template.svg';
function AsTemplateButton () {
const { t } = useTranslation();
const viewMeta = useViewMeta();
const navigate = useNavigate();
const handleClick = useCallback(() => {
const url = encodeURIComponent(window.location.href);
navigate(`/as-template?viewUrl=${url}&viewName=${viewMeta?.name || ''}&viewId=${viewMeta?.viewId || ''}`);
}, [navigate, viewMeta]);
const currentUser = useCurrentUser();
if (!currentUser) return null;
const isAppFlowyUser = currentUser.email?.endsWith('@appflowy.io');
if (!isAppFlowyUser) return null;
return (
<>
<Button
onClick={handleClick} className={'text-left justify-start'} variant={'text'}
color={'inherit'}
startIcon={<TemplateIcon className={'w-4 h-4'} />}
>
{t('template.asTemplate')}
</Button>
<Divider />
</>
);
}
export default AsTemplateButton;

View File

@ -0,0 +1,122 @@
import { TemplateSummary } from '@/application/template.type';
import RelatedTemplates from '@/components/as-template/related-template/RelatedTemplates';
import {
InputLabel,
TextField,
} from '@mui/material';
import React, { forwardRef, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export interface AsTemplateFormValue {
name: string;
description: string;
about: string;
related_view_ids: string[];
}
function AsTemplateForm ({ viewUrl, defaultValues, onSubmit, defaultRelatedTemplates }: {
viewUrl: string;
defaultValues: AsTemplateFormValue;
onSubmit: (data: AsTemplateFormValue) => void;
defaultRelatedTemplates?: TemplateSummary[];
}, ref: React.ForwardedRef<HTMLInputElement>) {
const { control, handleSubmit } = useForm<AsTemplateFormValue>({
defaultValues,
});
const { t } = useTranslation();
const iframeUrl = useMemo(() => {
const url = new URL(viewUrl);
url.searchParams.set('theme', 'light');
url.searchParams.set('template', 'true');
return url.toString();
}, [viewUrl]);
return (
<form
onSubmit={handleSubmit(onSubmit)}
className={'flex p-20 flex-col gap-4 max-w-screen h-fit w-[964px] min-w-0'}
>
<Controller
control={control}
rules={{
required: t('template.requiredField', {
field: t('template.name'),
}),
}}
render={({ field, fieldState }) => (
<TextField
error={!!fieldState.error}
helperText={fieldState.error?.message} required {...field}
label={t('template.name')}
/>
)}
name="name"
/>
<Controller
control={control}
rules={{
required: t('template.requiredField', {
field: t('template.description'),
}),
}}
render={({ field, fieldState }) => (
<TextField
error={!!fieldState.error}
helperText={fieldState.error?.message}
required
minRows={3}
multiline {...field} label={t('template.description')}
/>
)}
name="description"
/>
<Controller
control={control}
rules={{
required: t('template.requiredField', {
field: t('template.about'),
}),
}}
render={({ field, fieldState }) => (
<TextField
error={!!fieldState.error}
helperText={fieldState.error?.message}
required
minRows={3}
multiline
{...field}
label={t('template.about')}
/>
)}
name="about"
/>
<div className={'flex gap-2 flex-col w-full'}>
<InputLabel>{t('template.preview')}</InputLabel>
<iframe src={iframeUrl} className={'border aspect-video rounded-[16px] w-full bg-white'} />
</div>
<Controller
control={control}
render={({ field }) => (
<RelatedTemplates {...field} defaultRelatedTemplates={defaultRelatedTemplates} />
)}
name="related_view_ids"
/>
<input type="submit" hidden ref={ref} />
</form>
);
}
export default forwardRef(AsTemplateForm);

View File

@ -0,0 +1,41 @@
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import { useService } from '@/components/app/app.hooks';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
function DeleteTemplate ({ id, onClose, onDeleted, open }: {
id: string;
onClose: () => void;
onDeleted: () => void;
open: boolean;
}) {
const { t } = useTranslation();
const service = useService();
const onSubmit = useCallback(async () => {
try {
await service?.deleteTemplate(id);
onDeleted();
onClose();
} catch (error) {
notify.error('Failed to delete template');
}
}, [onDeleted, onClose, service, id]);
return (
<NormalModal
onOk={onSubmit}
danger
okText={t('button.delete')}
title={<div className={'text-left'}>{t('template.deleteTemplate')}</div>}
onCancel={onClose}
open={open}
onClose={onClose}
onClick={(e) => e.stopPropagation()}
>
{t('template.deleteTemplateDescription')}
</NormalModal>
);
}
export default DeleteTemplate;

View File

@ -0,0 +1,68 @@
import {
TemplateCategoryFormValues,
TemplateCategoryType,
TemplateIcon,
} from '@/application/template.type';
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import { useService } from '@/components/app/app.hooks';
import CategoryForm from '@/components/as-template/category/CategoryForm';
import MenuItem from '@mui/material/MenuItem';
import React, { useCallback, useMemo, useState } from 'react';
import { ReactComponent as AddIcon } from '@/assets/add.svg';
import { useTranslation } from 'react-i18next';
function AddCategory ({ searchText, onCreated }: {
searchText: string;
onCreated: () => void;
}) {
const { t } = useTranslation();
const submitRef = React.useRef<HTMLInputElement>(null);
const service = useService();
const defaultValues = useMemo(() => ({
name: searchText,
description: '',
icon: TemplateIcon.project,
bg_color: '#FFF5F5',
category_type: TemplateCategoryType.ByUseCase,
priority: 1,
}), [searchText]);
const [openModal, setOpenModal] = useState(false);
const onSubmit = useCallback(async (data: TemplateCategoryFormValues) => {
try {
await service?.addTemplateCategory(data);
onCreated();
setOpenModal(false);
} catch (error) {
notify.error('Failed to add category');
}
}, [onCreated, service]);
return (
<>
<MenuItem onClick={() => {
setOpenModal(true);
}} key={'add'} className={'flex gap-2 items-center'}
>
<AddIcon className={'w-6 h-6'} />
{searchText ? searchText : <span className={'text-text-caption'}>{t('template.addNewCategory')}</span>}
</MenuItem>
{openModal && <NormalModal
onOk={() => {
submitRef.current?.click();
}}
title={<div className={'text-left'}>{t('template.addNewCategory')}</div>}
open={openModal}
onClose={() => setOpenModal(false)}
onClick={e => e.stopPropagation()}
onCancel={() => setOpenModal(false)}
>
<CategoryForm defaultValues={defaultValues} ref={submitRef} onSubmit={onSubmit} />
</NormalModal>}
</>
);
}
export default AddCategory;

View File

@ -0,0 +1,53 @@
import { IconButton, InputLabel, Tooltip } from '@mui/material';
import React, { forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
const colors = [
'#FFF5F5',
'#FFF9DB',
'#FFF0F6',
'#FFF4E6',
'#F3F0FF',
'#DAD8FF',
'#FFF0F6',
'#FFE4E1',
'#E2EFF5',
'#C5D0E6',
'#FFE0E6',
'#F0DFF0',
'#E0FFFF',
'#AFEEEE',
];
function BgColorPicker ({ value, onChange }: {
value: string;
onChange: (value: string) => void;
}, ref: React.Ref<HTMLDivElement>) {
const { t } = useTranslation();
return (
<div ref={ref} className={'flex flex-col gap-2'}>
<InputLabel>{t('template.category.bgColor')}</InputLabel>
<div className={'flex gap-2 flex-wrap'}>
{colors.map((color, index) => {
return (
<Tooltip title={color} key={index} arrow={true} placement={'top'}>
<IconButton
className={`flex items-center justify-center cursor-pointer p-2`}
style={{
backgroundColor: value === color ? 'var(--fill-list-hover)' : undefined,
}}
key={index}
onClick={() => onChange(color)}
>
<div className={'rounded-full w-6 h-6'} style={{ backgroundColor: color }} />
</IconButton>
</Tooltip>
);
})}
</div>
</div>
);
}
export default forwardRef(BgColorPicker);

View File

@ -0,0 +1,122 @@
import { Popover } from '@/components/_shared/popover';
import AddCategory from '@/components/as-template/category/AddCategory';
import CategoryItem from '@/components/as-template/category/CategoryItem';
import { useLoadCategories } from '@/components/as-template/hooks';
import { CategoryIcon } from '@/components/as-template/icons';
import { Chip, CircularProgress, OutlinedInput, Typography } from '@mui/material';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as ArrowRight } from '@/assets/arrow_right.svg';
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
disableAutoFocus: true,
disableRestoreFocus: true,
disableEnforceFocus: true,
PaperProps: {
className: 'p-2 appflowy-scroller',
},
};
function Categories ({
value,
onChange,
}: {
value: string[];
onChange: React.Dispatch<React.SetStateAction<string[]>>
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [searchText, setSearchText] = useState('');
const {
categories,
loading,
loadCategories,
} = useLoadCategories({
searchText,
});
const selectedCategories = useMemo(() => {
return categories.filter((category) => value.includes(category.id));
}, [categories, value]);
const handleSelect = useCallback((id: string) => {
onChange(prev => {
if (prev.includes(id)) {
return prev.filter((categoryId) => categoryId !== id);
}
return [...prev, id];
});
}, [onChange]);
useEffect(() => {
void loadCategories();
}, [loadCategories]);
return (
<div className={'flex flex-col gap-4'}>
<Typography variant={'h6'} className={'text-text-caption'}>{t('template.categories')}</Typography>
<div className={'flex items-center gap-2'}>
<OutlinedInput
value={searchText}
placeholder={t('template.category.typeToSearch')}
onChange={(e) => setSearchText(e.target.value)}
endAdornment={
loading ? <CircularProgress size={'small'} /> :
<ArrowRight
className={`w-4 h-4 ${open ? '-rotate-90' : 'rotate-90'} text-text-caption`}
/>
}
size={'small'}
onClick={async () => {
setOpen(true);
}}
className={'bg-bg-body flex-1'}
ref={ref}
/>
<Popover {...MenuProps} open={open} anchorEl={ref.current} onClose={() => setOpen(false)}>
<div className={'flex flex-col gap-1 w-full'} style={{
minWidth: ref.current?.clientWidth,
maxHeight: ITEM_HEIGHT * 10 + ITEM_PADDING_TOP,
}}
>
<AddCategory searchText={searchText} onCreated={loadCategories} />
{categories.map((category) => (
<CategoryItem
key={category.id}
category={category}
selected={value.includes(category.id)}
onClick={() => handleSelect(category.id)}
reloadCategories={loadCategories}
/>
))}
</div>
</Popover>
</div>
<div className={'flex flex-wrap gap-2'}>
{selectedCategories.map((category) => (
<Chip
key={category.id}
icon={<CategoryIcon icon={category.icon} />}
label={category.name}
style={{
backgroundColor: category.bg_color,
}}
className={'border-transparent text-black template-category px-3'}
variant="outlined"
/>
))}
</div>
</div>
);
}
export default Categories;

View File

@ -0,0 +1,149 @@
import { TemplateCategoryFormValues } from '@/application/template.type';
import BgColorPicker from '@/components/as-template/category/BgColorPicker';
import IconPicker from '@/components/as-template/category/IconPicker';
import { FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, TextField } from '@mui/material';
import React, { forwardRef } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
const CategoryForm = forwardRef<HTMLInputElement, {
defaultValues: TemplateCategoryFormValues;
onSubmit: (data: TemplateCategoryFormValues) => void;
}>(({
defaultValues,
onSubmit,
}, ref) => {
const { t } = useTranslation();
const {
control,
handleSubmit,
} = useForm<TemplateCategoryFormValues>({
defaultValues,
});
return (
<form className={'flex flex-col gap-4 py-2'} onSubmit={handleSubmit(onSubmit)}
>
<Controller
control={control}
rules={{
required: t('template.requiredField', {
field: t('template.category.name'),
}),
}}
render={({ field, fieldState }) => (
<TextField
error={!!fieldState.error}
helperText={fieldState.error?.message} required {...field}
label={t('template.category.name')}
/>
)}
name="name"
/>
<Controller
control={control}
rules={{
required: t('template.requiredField', {
field: t('template.category.desc'),
}),
}}
render={({ field, fieldState }) => (
<TextField
multiline
minRows={3}
error={!!fieldState.error}
helperText={fieldState.error?.message} required {...field}
label={t('template.category.desc')}
/>
)}
name="description"
/>
<Controller
control={control}
rules={{
required: t('template.requiredField', {
field: t('template.category.icon'),
}),
}}
render={({ field }) => (
<IconPicker
{...field}
/>
)}
name="icon"
/>
<Controller
control={control}
rules={{
required: t('template.requiredField', {
field: t('template.category.bgColor'),
}),
}}
render={({ field }) => (
<BgColorPicker
{...field}
/>
)}
name="bg_color"
/>
<Controller
control={control}
rules={{
required: t('template.requiredField', {
field: t('template.category.type'),
}),
}}
name="category_type"
render={({ field }) => (
<FormControl>
<FormLabel>{t('template.category.type')}</FormLabel>
<RadioGroup
row
value={field.value}
onChange={(e) => {
field.onChange(parseInt(e.target.value, 10));
}}
>
<FormControlLabel value={0} control={<Radio />} label={t('template.category.byUseCase')} />
<FormControlLabel value={1} control={<Radio />} label={t('template.category.byFeature')} />
</RadioGroup>
</FormControl>
)}
/>
<Controller
control={control}
rules={{
required: t('template.requiredField', {
field: t('template.category.priority'),
}),
}}
render={({ field, fieldState }) => (
<TextField
type="number"
error={!!fieldState.error}
helperText={fieldState.error?.message}
required
label={t('template.category.priority')}
{...field}
onChange={(e) => {
field.onChange(parseInt(e.target.value, 10));
}}
/>
)}
name="priority"
/>
<input type="submit" ref={ref} style={{ display: 'none' }} />
</form>
);
});
export default CategoryForm;

View File

@ -0,0 +1,90 @@
import { TemplateCategory } from '@/application/template.type';
import DeleteCategory from '@/components/as-template/category/DeleteCategory';
import EditCategory from '@/components/as-template/category/EditCategory';
import { CategoryIcon } from '@/components/as-template/icons';
import { Chip, IconButton, Tooltip } from '@mui/material';
import MenuItem from '@mui/material/MenuItem';
import React, { useState } from 'react';
import { ReactComponent as CheckIcon } from '@/assets/selected.svg';
import { ReactComponent as EditIcon } from '@/assets/edit.svg';
import { ReactComponent as DeleteIcon } from '@/assets/trash.svg';
import { useTranslation } from 'react-i18next';
function CategoryItem ({
onClick,
category,
selected,
reloadCategories,
}: {
onClick: () => void;
category: TemplateCategory;
selected: boolean;
reloadCategories: () => void;
}) {
const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
return (
<MenuItem
className={'flex items-center gap-2 w-full justify-between'} onClick={onClick}
onMouseLeave={() => setHovered(false)} onMouseEnter={() => setHovered(true)}
>
<Chip
icon={<CategoryIcon icon={category.icon} />}
label={category.name}
variant="outlined"
className={'border-transparent template-category px-4'}
style={{
backgroundColor: category.bg_color,
color: 'black',
}}
/>
<div style={{
display: hovered ? 'flex' : 'none',
}} className={'flex gap-1 items-center'}
>
<Tooltip title={t('button.edit')}>
<IconButton size={'small'} onClick={(e) => {
e.stopPropagation();
setEditModalOpen(true);
}}
>
<EditIcon className={'w-4 h-4'} />
</IconButton>
</Tooltip>
<Tooltip title={t('button.delete')}>
<IconButton size={'small'} onClick={(e) => {
e.stopPropagation();
setDeleteModalOpen(true);
}}
>
<DeleteIcon className={'w-4 h-4 text-function-error'} />
</IconButton>
</Tooltip>
</div>
{selected && !hovered && <CheckIcon className={'w-4 h-4 text-fill-default'} />}
{
editModalOpen &&
<EditCategory
category={category}
onClose={() => setEditModalOpen(false)}
openModal={editModalOpen}
onUpdated={reloadCategories}
/>
}
{
deleteModalOpen &&
<DeleteCategory
id={category.id}
onClose={() => setDeleteModalOpen(false)}
open={deleteModalOpen}
onDeleted={reloadCategories}
/>
}
</MenuItem>
);
}
export default CategoryItem;

View File

@ -0,0 +1,41 @@
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import { useService } from '@/components/app/app.hooks';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
function DeleteCategory ({ id, onClose, onDeleted, open }: {
id: string;
onClose: () => void;
onDeleted: () => void;
open: boolean;
}) {
const { t } = useTranslation();
const service = useService();
const onSubmit = useCallback(async () => {
try {
await service?.deleteTemplateCategory(id);
onDeleted();
onClose();
} catch (error) {
notify.error('Failed to delete category');
}
}, [onDeleted, onClose, service, id]);
return (
<NormalModal
onOk={onSubmit}
danger
okText={t('button.delete')}
title={<div className={'text-left'}>{t('template.category.deleteCategory')}</div>}
onCancel={onClose}
open={open}
onClose={onClose}
onClick={(e) => e.stopPropagation()}
>
{t('template.category.deleteCategoryDescription')}
</NormalModal>
);
}
export default DeleteCategory;

View File

@ -0,0 +1,53 @@
import {
TemplateCategory,
TemplateCategoryFormValues,
} from '@/application/template.type';
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import { useService } from '@/components/app/app.hooks';
import CategoryForm from '@/components/as-template/category/CategoryForm';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
function EditCategory ({
category,
onUpdated,
openModal,
onClose,
}: {
category: TemplateCategory;
onUpdated: () => void;
openModal: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const submitRef = React.useRef<HTMLInputElement>(null);
const service = useService();
const onSubmit = useCallback(async (data: TemplateCategoryFormValues) => {
console.log('data', data);
try {
await service?.updateTemplateCategory(category.id, data);
onUpdated();
onClose();
} catch (error) {
notify.error('Failed to update category');
}
}, [onUpdated, onClose, service, category.id]);
return (
<NormalModal
onClick={e => e.stopPropagation()}
onOk={() => {
submitRef.current?.click();
}}
title={<div className={'text-left'}>{t('template.editCategory')}</div>}
onCancel={onClose}
open={openModal}
onClose={onClose}
>
<CategoryForm defaultValues={category} ref={submitRef} onSubmit={onSubmit} />
</NormalModal>
);
}
export default EditCategory;

View File

@ -0,0 +1,38 @@
import { TemplateIcon } from '@/application/template.type';
import { CategoryIcon } from '@/components/as-template/icons';
import { IconButton, InputLabel } from '@mui/material';
import React, { forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
const options = Object.values(TemplateIcon);
const IconPicker = forwardRef<HTMLDivElement, {
value: string;
onChange: (value: string) => void;
}>(({ value, onChange }, ref) => {
const { t } = useTranslation();
return (
<div ref={ref} className={'flex flex-col gap-2'}>
<InputLabel>{t('template.category.icons')}</InputLabel>
<div className={'flex gap-2 flex-wrap'}>
{options.map((icon) => {
return (
<IconButton
className={`flex items-center justify-center p-2 w-10 h-10`}
style={{
backgroundColor: value === icon ? 'var(--fill-list-hover)' : undefined,
}}
key={icon}
onClick={() => onChange(icon)}
>
<CategoryIcon icon={icon} />
</IconButton>
);
})}
</div>
</div>
);
});
export default IconPicker;

View File

@ -0,0 +1,62 @@
import { TemplateCreator } from '@/application/template.type';
import { accountLinkIcon } from '@/components/as-template/icons';
import { TextField } from '@mui/material';
import React, { useState } from 'react';
const accountLinkTypes = ['youtube', 'twitter', 'instagram', 'facebook', 'linkedin', 'tiktok', 'website'] as const;
const placeholders = {
youtube: 'https://www.youtube.com/channel/UC...',
twitter: 'https://twitter.com/...',
instagram: 'https://www.instagram.com/...',
facebook: 'https://www.facebook.com/...',
linkedin: 'https://www.linkedin.com/in/...',
tiktok: 'https://www.tiktok.com/@...',
website: 'https://',
};
function AccountLinks ({
value,
onChange,
}: {
value: TemplateCreator['account_links'];
onChange: (value: TemplateCreator['account_links']) => void;
}) {
const [state, setState] = useState<TemplateCreator['account_links']>(value);
return (
<div className={'flex flex-wrap gap-4 h-fit'}>
{accountLinkTypes.map((type, index) => (
<TextField
name={type}
label={type}
placeholder={placeholders[type]}
InputProps={{
startAdornment: (
<div className={'flex items-center justify-center mr-2'}>
{accountLinkIcon(type)}
</div>
),
}}
key={index}
value={state?.find((link) => link.link_type === type)?.url || ''}
onChange={(e) => {
const url = e.target.value;
const newLinks = state?.filter((link) => link.link_type !== type) || [];
if (url) {
newLinks.push({ link_type: type, url });
}
setState(newLinks);
onChange(newLinks);
}}
/>
))}
</div>
);
}
export default AccountLinks;

View File

@ -0,0 +1,66 @@
import {
TemplateCreatorFormValues,
} from '@/application/template.type';
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import { useService } from '@/components/app/app.hooks';
import CreatorForm from '@/components/as-template/creator/CreatorForm';
import MenuItem from '@mui/material/MenuItem';
import React, { useCallback, useMemo, useState } from 'react';
import { ReactComponent as AddIcon } from '@/assets/add.svg';
import { useTranslation } from 'react-i18next';
function AddCreator ({ searchText, onCreated }: {
searchText: string;
onCreated: () => void;
}) {
const { t } = useTranslation();
const submitRef = React.useRef<HTMLInputElement>(null);
const service = useService();
const defaultValues = useMemo(() => ({
name: searchText,
avatar_url: '',
account_links: [],
}), [searchText]);
const [openModal, setOpenModal] = useState(false);
const handleClose = useCallback(() => {
setOpenModal(false);
}, []);
const onSubmit = useCallback(async (data: TemplateCreatorFormValues) => {
console.log('data', data);
try {
await service?.createTemplateCreator(data);
onCreated();
handleClose();
} catch (error) {
notify.error('Failed to create creator');
}
}, [onCreated, service, handleClose]);
return (
<>
<MenuItem key={'add'} className={'flex gap-2 items-center'} onClick={() => setOpenModal(true)}>
<AddIcon className={'w-6 h-6'} />
{searchText ? searchText : <span className={'text-text-caption'}>{t('template.addNewCreator')}</span>}
</MenuItem>
{openModal && <NormalModal
onClick={e => e.stopPropagation()}
onCancel={handleClose}
onOk={() => {
submitRef.current?.click();
}} title={<div className={'text-left'}>{t('template.addNewCreator')}</div>} open={openModal}
onClose={handleClose}
>
<div className={'overflow-hidden w-[500px]'}>
<CreatorForm ref={submitRef} onSubmit={onSubmit} defaultValues={defaultValues} />
</div>
</NormalModal>}
</>
);
}
export default AddCreator;

View File

@ -0,0 +1,128 @@
import { Popover } from '@/components/_shared/popover';
import AddCreator from '@/components/as-template/creator/AddCreator';
import CreatorAvatar from '@/components/as-template/creator/CreatorAvatar';
import CreatorItem from '@/components/as-template/creator/CreatorItem';
import { useLoadCreators } from '@/components/as-template/hooks';
import { accountLinkIcon } from '@/components/as-template/icons';
import { CircularProgress, OutlinedInput, Tooltip, Typography, Button } from '@mui/material';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as ArrowRight } from '@/assets/arrow_right.svg';
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
disableAutoFocus: true,
disableRestoreFocus: true,
disableEnforceFocus: true,
PaperProps: {
className: 'p-2 appflowy-scroller',
},
};
function Creator ({ value, onChange }: {
value?: string;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const [searchText, setSearchText] = useState('');
const {
creators,
loading,
loadCreators,
} = useLoadCreators({
searchText,
});
useEffect(() => {
void loadCreators();
}, [loadCreators]);
useEffect(() => {
if (!value && creators[0]) {
onChange(creators[0].id);
}
}, [creators, value, onChange]);
const handleSelect = useCallback((id: string) => {
onChange(id);
setOpen(false);
}, [onChange]);
const selectedCreator = useMemo(() => creators.find((creator) => creator.id === value), [creators, value]);
return (
<div className={'flex flex-col gap-4'}>
<Typography variant={'h6'} className={'text-text-caption'}>{t('template.creator.label')}</Typography>
<div className={'flex gap-4 flex-col'}>
<Button
variant={'outlined'}
color={'inherit'}
ref={ref}
className={'flex items-center gap-2 justify-between'}
onClick={() => {
setOpen(true);
}}
>
<CreatorAvatar size={40} src={selectedCreator?.avatar_url || ''} name={selectedCreator?.name || ''} />
<div className={'text-left flex-1'}>{selectedCreator?.name}</div>
<ArrowRight className={'rotate-90 text-text-caption'} />
</Button>
<div className={'flex flex-wrap gap-2'}>
{selectedCreator?.account_links?.map((link) => {
return (
<Tooltip title={link.url} key={link.link_type} placement={'top'} arrow>
<a href={link.url} key={link.link_type} target={'_blank'}
className={'flex w-10 h-10 items-center justify-between p-3 rounded-full border border-line-border hover:border-content-blue-400 hover:text-content-blue-400'}
>
{accountLinkIcon(link.link_type)}
</a>
</Tooltip>
);
})}
</div>
<Popover {...MenuProps} open={open} anchorEl={ref.current} onClose={() => setOpen(false)}>
<div className={'flex flex-col gap-2'} style={{
minWidth: ref.current?.clientWidth,
maxHeight: ITEM_HEIGHT * 10 + ITEM_PADDING_TOP,
}}
>
<OutlinedInput
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
endAdornment={
loading ? <CircularProgress size={'small'} /> : null
}
size={'small'}
className={'bg-bg-body flex-1'}
placeholder={t('template.creator.typeToSearch')}
/>
<AddCreator searchText={searchText} onCreated={loadCreators} />
{
creators.map((creator) => {
return (
<CreatorItem
key={creator.id}
creator={creator}
onClick={() => handleSelect(creator.id)}
reloadCreators={loadCreators}
selected={selectedCreator?.id === creator.id}
/>
);
})
}
</div>
</Popover>
</div>
</div>
);
}
export default Creator;

View File

@ -0,0 +1,124 @@
import { NormalModal } from '@/components/_shared/modal';
import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs';
import UploadAvatar from '@/components/as-template/creator/UploadAvatar';
import { stringAvatar } from '@/utils/color';
import { Avatar, Button, OutlinedInput, Tooltip } from '@mui/material';
import React, { useEffect, useMemo } from 'react';
import { ReactComponent as CloudUploadIcon } from '@/assets/cloud_add.svg';
import { useTranslation } from 'react-i18next';
function CreatorAvatar ({ src, name, enableUpload, onChange, size }: {
src: string;
name: string;
enableUpload?: boolean;
onChange?: (url: string) => void;
size?: number;
}) {
const { t } = useTranslation();
const [showUpload, setShowUpload] = React.useState(false);
const [tab, setTab] = React.useState(0);
const avatarProps = useMemo(() => {
return stringAvatar(name || '');
}, [name]);
const [openModal, setOpenModal] = React.useState(false);
const [imageUrl, setImageUrl] = React.useState(src);
useEffect(() => {
setImageUrl(src);
}, [openModal, src]);
return (
<>
<div
style={{
width: size || undefined,
height: size || undefined,
}}
className={'relative w-full h-full cursor-pointer'}
onMouseLeave={() => setShowUpload(false)}
onMouseEnter={() => {
setShowUpload(true);
}}
onClick={(e) => {
e.stopPropagation();
}}
>
<Avatar src={src} className={'w-full h-full object-cover p-2'} {...avatarProps} sx={{
...avatarProps?.sx,
bgcolor: imageUrl ? 'var(--bg-body)' : avatarProps?.sx.bgcolor,
width: size || undefined,
height: size || undefined,
}}
/>
{enableUpload && showUpload && (
<Tooltip title={t('template.creator.uploadAvatar')} arrow>
<Button
component="label"
role={undefined}
variant="contained"
className={'absolute left-0 top-0 min-w-full p-0 flex items-center justify-center w-full h-full rounded-full bg-black bg-opacity-50'}
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
setOpenModal(true);
}}
>
<CloudUploadIcon className={'w-[60%] h-[60%]'} />
</Button>
</Tooltip>
)}
</div>
{openModal &&
<NormalModal
onClick={e => e.stopPropagation()}
okButtonProps={{
disabled: !imageUrl,
}}
onOk={() => {
if (!imageUrl) return;
onChange?.(imageUrl);
setOpenModal(false);
}} title={t('template.uploadAvatar')}
onCancel={() => setOpenModal(false)}
onClose={() => setOpenModal(false)} open={openModal}
>
<div className={'min-w-[400px] flex flex-col gap-4'}>
<ViewTabs value={tab} onChange={(_, newValue) => {
setTab(newValue);
setImageUrl(src);
}}
>
<ViewTab value={0} label={t('document.imageBlock.embedLink.label')} />
<ViewTab value={1} label={t('button.upload')} />
</ViewTabs>
<TabPanel className={'w-full'} value={tab} index={0}>
<OutlinedInput
size={'small'}
value={imageUrl}
fullWidth
onChange={
(e) => {
setImageUrl(e.target.value);
}
}
placeholder={t('document.imageBlock.embedLink.placeholder')}
/>
</TabPanel>
<TabPanel className={'w-full flex flex-col gap-2'} value={tab} index={1}>
<UploadAvatar onChange={setImageUrl} />
</TabPanel>
</div>
</NormalModal>}
</>
);
}
export default CreatorAvatar;

View File

@ -0,0 +1,102 @@
import { TemplateCreatorFormValues } from '@/application/template.type';
import AccountLinks from '@/components/as-template/creator/AccountLinks';
import CreatorAvatar from '@/components/as-template/creator/CreatorAvatar';
import { FormControl, FormLabel, TextField } from '@mui/material';
import React, { forwardRef } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
const CreatorForm = forwardRef<HTMLInputElement, {
defaultValues?: TemplateCreatorFormValues;
onSubmit: (data: TemplateCreatorFormValues) => void;
}>(({
defaultValues,
onSubmit,
}, ref) => {
const { t } = useTranslation();
const {
watch,
control,
handleSubmit,
} = useForm<TemplateCreatorFormValues>({
defaultValues,
});
const name = watch('name');
return (
<form
className={'flex flex-col gap-4 py-5 overflow-hidden'}
onSubmit={handleSubmit(onSubmit)}
onClick={e => e.stopPropagation()}
>
<Controller
control={control}
name="avatar_url"
rules={{
required: false,
}}
render={({ field }) => (
<div className={'flex items-center justify-center'}>
<CreatorAvatar size={80} src={field.value} enableUpload onChange={field.onChange} name={name} />
</div>
)}
/>
<Controller
name="name"
control={control}
rules={{
required: t('template.requiredField', {
field: t('template.creator.name'),
}),
}}
render={({ field, fieldState }) => (
<TextField
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message}
required
{...field}
label={t('template.creator.name')}
/>
)}
/>
<Controller
control={control}
name="account_links"
rules={{
validate: (value) => {
if (!value) return;
const links = value.filter((link) => link.url.length > 0);
if (links.length === 0) {
return t('template.requiredField', {
field: t('template.creator.accountLinks'),
});
}
return true;
},
}}
render={({ field, fieldState }) => (
<FormControl className={'flex flex-col gap-4'} error={!!fieldState.error}>
<FormLabel error={!!fieldState.error} required className={'text-text-caption flex items-center text-md'}>{
t('template.creator.accountLinks')
}</FormLabel>
<AccountLinks value={field.value} onChange={field.onChange} />
</FormControl>
)}
/>
<input type="submit" hidden ref={ref} />
</form>
);
});
export default CreatorForm;

View File

@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { TemplateCreator } from '@/application/template.type';
import DeleteCreator from '@/components/as-template/creator/DeleteCreator';
import CreatorAvatar from '@/components/as-template/creator/CreatorAvatar';
import EditCreator from '@/components/as-template/creator/EditCreator';
import { IconButton, Tooltip } from '@mui/material';
import MenuItem from '@mui/material/MenuItem';
import { ReactComponent as CheckIcon } from '@/assets/selected.svg';
import { ReactComponent as EditIcon } from '@/assets/edit.svg';
import { ReactComponent as DeleteIcon } from '@/assets/trash.svg';
import { useTranslation } from 'react-i18next';
function CreatorItem ({
onClick,
creator,
selected,
reloadCreators,
}: {
onClick: () => void;
creator: TemplateCreator;
selected: boolean;
reloadCreators: () => void;
}) {
const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
return (
<MenuItem
className={'flex items-center gap-2 justify-between'}
onClick={onClick}
onMouseLeave={() => setHovered(false)}
onMouseEnter={() => setHovered(true)}
>
<div
className={'flex items-center gap-2 border-transparent'}
>
<CreatorAvatar size={40} src={creator.avatar_url} name={creator.name} />
<span className={'text-text-caption'}>{creator.name}</span>
</div>
<div style={{
display: hovered ? 'flex' : 'none',
}} className={'flex gap-1 items-center'}
>
<Tooltip title={t('button.edit')}>
<IconButton size={'small'} onClick={(e) => {
e.stopPropagation();
setEditModalOpen(true);
}}
>
<EditIcon className={'w-4 h-4'} />
</IconButton>
</Tooltip>
<Tooltip title={t('button.delete')}>
<IconButton size={'small'} onClick={(e) => {
e.stopPropagation();
setDeleteModalOpen(true);
}}
>
<DeleteIcon className={'w-4 h-4 text-function-error'} />
</IconButton>
</Tooltip>
</div>
{selected && !hovered && <CheckIcon className={'w-4 h-4 text-fill-default'} />}
{
editModalOpen &&
<EditCreator
creator={creator}
onClose={() => setEditModalOpen(false)}
openModal={editModalOpen}
onUpdated={reloadCreators}
/>
}
{
deleteModalOpen &&
<DeleteCreator
id={creator.id}
onClose={() => setDeleteModalOpen(false)}
open={deleteModalOpen}
onDeleted={reloadCreators}
/>
}
</MenuItem>
);
}
export default CreatorItem;

View File

@ -0,0 +1,41 @@
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import { useService } from '@/components/app/app.hooks';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
function DeleteCreator ({ id, onClose, onDeleted, open }: {
id: string;
onClose: () => void;
onDeleted: () => void;
open: boolean;
}) {
const { t } = useTranslation();
const service = useService();
const onSubmit = useCallback(async () => {
try {
await service?.deleteTemplateCreator(id);
onDeleted();
onClose();
} catch (error) {
notify.error('Failed to delete creator');
}
}, [onDeleted, onClose, service, id]);
return (
<NormalModal
onOk={onSubmit}
danger
okText={t('button.delete')}
title={<div className={'text-left'}>{t('template.creator.deleteCreator')}</div>}
onCancel={onClose}
open={open}
onClose={onClose}
onClick={(e) => e.stopPropagation()}
>
{t('template.creator.deleteCreatorDescription')}
</NormalModal>
);
}
export default DeleteCreator;

View File

@ -0,0 +1,51 @@
import { TemplateCreator, TemplateCreatorFormValues } from '@/application/template.type';
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import { useService } from '@/components/app/app.hooks';
import CreatorForm from '@/components/as-template/creator/CreatorForm';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
function EditCreator ({
creator,
onUpdated,
openModal,
onClose,
}: {
creator: TemplateCreator;
onUpdated: () => void;
openModal: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const submitRef = React.useRef<HTMLInputElement>(null);
const service = useService();
const onSubmit = useCallback(async (data: TemplateCreatorFormValues) => {
try {
await service?.updateTemplateCreator(creator.id, data);
onUpdated();
onClose();
} catch (error) {
notify.error('Failed to update creator');
}
}, [onUpdated, service, onClose, creator.id]);
return (
<NormalModal
onCancel={onClose}
onOk={() => {
submitRef.current?.click();
}}
onClick={e => e.stopPropagation()}
title={<div className={'text-left'}>{t('template.editCreator')}</div>} open={openModal}
onClose={onClose}
>
<div className={'overflow-hidden w-[500px]'}>
<CreatorForm defaultValues={creator} ref={submitRef} onSubmit={onSubmit} />
</div>
</NormalModal>
);
}
export default EditCreator;

View File

@ -0,0 +1,104 @@
import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone';
import { useService } from '@/components/app/app.hooks';
import { CircularProgress, IconButton, Tooltip } from '@mui/material';
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as LinkIcon } from '@/assets/link.svg';
import { ReactComponent as DeleteIcon } from '@/assets/trash.svg';
import { ReactComponent as CheckIcon } from '@/assets/check_circle.svg';
function UploadAvatar ({
onChange,
}: {
onChange: (url: string) => void;
}) {
const { t } = useTranslation();
const [file, setFile] = React.useState<File | null>(null);
const [uploadStatus, setUploadStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const service = useService();
const [hovered, setHovered] = React.useState(false);
const uploadStatusText = useMemo(() => {
switch (uploadStatus) {
case 'success':
return t('fileDropzone.uploadSuccess');
case 'error':
return t('fileDropzone.uploadFailed');
default:
return t('fileDropzone.uploading');
}
}, [uploadStatus, t]);
const handleUpload = useCallback(async (file: File) => {
setUploadStatus('loading');
try {
const url = await service?.uploadFileToCDN(file);
if (!url) throw new Error('Failed to upload file');
onChange(url);
setUploadStatus('success');
} catch (error) {
onChange('');
setUploadStatus('error');
}
}, [service, onChange]);
return (
<>
<FileDropzone
accept={'image/*'}
onChange={files => {
setFile(files[0]);
void handleUpload(files[0]);
}}
/>
{file && (
<div className={'flex gap-2 items-center'}>
<div className={'w-[80px] aspect-square rounded-xl border border-line-divider'}
>
<img
src={URL.createObjectURL(file)}
alt={file.name}
className={'w-full h-full'}
/>
</div>
<div
className={'flex items-center gap-2 overflow-hidden w-full hover:bg-fill-list-hover rounded-lg p-1'}
onMouseLeave={() => setHovered(false)}
onMouseEnter={() => setHovered(true)}
style={{
color: uploadStatus === 'error' ? 'var(--function-error)' : undefined,
}}
>
{uploadStatus === 'loading' ? <CircularProgress size={20} /> : <LinkIcon className={'w-5 h-5'} />}
<Tooltip title={uploadStatusText} placement={'bottom-start'}>
<div className={'flex-1 truncate cursor-pointer'}>{file.name}</div>
</Tooltip>
{
uploadStatus === 'success' && !hovered && <CheckIcon className={'w-5 h-5 text-function-success'} />
}
{hovered && <Tooltip title={t('button.remove')} arrow>
<IconButton
onClick={() => {
setFile(null);
onChange('');
}}
size={'small'}
color={'error'}
>
<DeleteIcon className={'w-5 h-5'} />
</IconButton>
</Tooltip>}
</div>
</div>
)}
</>
);
}
export default UploadAvatar;

View File

@ -0,0 +1,124 @@
import { Template, TemplateCategory, TemplateCreator, TemplateSummary } from '@/application/template.type';
import { notify } from '@/components/_shared/notify';
import { useService } from '@/components/app/app.hooks';
import { useCallback, useMemo, useState } from 'react';
export function useLoadCategoryTemplates () {
const [loading, setLoading] = useState(false);
const [templates, setTemplates] = useState<TemplateSummary[]>([]);
const service = useService();
const loadCategoryTemplates = useCallback(async (categoryId: string, nameContains?: string) => {
try {
setLoading(true);
const data = await service?.getTemplates({ categoryId, nameContains });
if (!data) throw new Error('Failed to fetch templates');
setTemplates(data);
} catch (error) {
notify.error('Failed to fetch ${categoryId} templates');
return Promise.reject(error);
} finally {
setLoading(false);
}
}, [service]);
return {
templates,
loadCategoryTemplates,
loading,
};
}
export function useLoadTemplate (id: string) {
const [loading, setLoading] = useState(false);
const [template, setTemplate] = useState<Template | null>(null);
const service = useService();
const loadTemplate = useCallback(async () => {
try {
setLoading(true);
const data = await service?.getTemplateById(id);
if (!data) return;
setTemplate(data);
} catch (error) {
// don't show error notification
} finally {
setLoading(false);
}
}, [service, id]);
return {
template,
loadTemplate,
loading,
};
}
export function useLoadCategories (props?: {
searchText?: string;
}) {
const searchText = props?.searchText || '';
const [loading, setLoading] = useState(false);
const [categories, setCategories] = useState<TemplateCategory[]>([]);
const service = useService();
const loadCategories = useCallback(async () => {
try {
setLoading(true);
const data = await service?.getTemplateCategories();
if (!data) throw new Error('Failed to fetch categories');
setCategories(data);
} catch (error) {
notify.error('Failed to fetch categories');
return Promise.reject(error);
} finally {
setLoading(false);
}
}, [service]);
const filteredCategories = useMemo(() => categories.filter((category) => {
return searchText ? category.name.toLowerCase().includes(searchText.toLowerCase()) : true;
}), [categories, searchText]);
return {
categories: filteredCategories,
loadCategories,
loading,
};
}
export function useLoadCreators ({
searchText,
}: {
searchText: string;
}) {
const [loading, setLoading] = useState(false);
const [creators, setCreators] = useState<TemplateCreator[]>([]);
const service = useService();
const loadCreators = useCallback(async () => {
try {
setLoading(true);
const data = await service?.getTemplateCreators();
if (!data) throw new Error('Failed to fetch creators');
setCreators(data);
} catch (error) {
notify.error('Failed to fetch creators');
} finally {
setLoading(false);
}
}, [service]);
const filteredCreators = useMemo(() => creators.filter((creator) => {
return creator.name.toLowerCase().includes(searchText.toLowerCase());
}), [creators, searchText]);
return {
creators: filteredCreators,
loadCreators,
loading,
};
}

View File

@ -0,0 +1,62 @@
import { TemplateIcon } from '@/application/template.type';
import { ReactComponent as Youtube } from '@/assets/youtube.svg';
import { ReactComponent as Twitter } from '@/assets/twitter.svg';
import { ReactComponent as Instagram } from '@/assets/instagram.svg';
import { ReactComponent as Facebook } from '@/assets/facebook.svg';
import { ReactComponent as Tiktok } from '@/assets/tiktok.svg';
import { ReactComponent as Website } from '@/assets/website.svg';
import { ReactComponent as LinkedInIcon } from '@/assets/linkedin.svg';
import { ReactComponent as LightningIcon } from '@/assets/lightning.svg';
import { ReactComponent as MonitorIcon } from '@/assets/monitor.svg';
import { ReactComponent as Lightbulb } from '@/assets/lightbulb.svg';
import { ReactComponent as GraduationCap } from '@/assets/graduation_cap.svg';
import { ReactComponent as Database } from '@/assets/database.svg';
import { ReactComponent as Columns } from '@/assets/columns.svg';
import { ReactComponent as UsersThree } from '@/assets/users_three.svg';
import { ReactComponent as ChatCircleText } from '@/assets/chat_circle_text.svg';
import { ReactComponent as MegaphoneSimple } from '@/assets/megaphone_simple.svg';
import { ReactComponent as StarIcon } from '@/assets/person.svg';
import { ReactComponent as CurrencyCircleDollar } from '@/assets/currency_circle_dollar.svg';
import { ReactComponent as Sparkle } from '@/assets/sparkle.svg';
import { ReactComponent as Notepad } from '@/assets/notepad.svg';
import { ReactComponent as Book } from '@/assets/book.svg';
const categoryIcons: Record<string, React.ReactElement> = {
[TemplateIcon.project]: <LightningIcon />,
[TemplateIcon.engineering]: <MonitorIcon />,
[TemplateIcon.startups]: <Lightbulb />,
[TemplateIcon.schools]: <GraduationCap />,
[TemplateIcon.marketing]: <MegaphoneSimple />,
[TemplateIcon.management]: <ChatCircleText />,
[TemplateIcon.humanResources]: <StarIcon />,
[TemplateIcon.sales]: <CurrencyCircleDollar />,
[TemplateIcon.teamMeetings]: <UsersThree />,
[TemplateIcon.ai]: <Sparkle />,
[TemplateIcon.docs]: <Notepad />,
[TemplateIcon.wiki]: <Book />,
[TemplateIcon.database]: <Database />,
[TemplateIcon.kanban]: <Columns />,
};
export function CategoryIcon ({ icon }: { icon: TemplateIcon }) {
return categoryIcons[icon] || null;
}
export function accountLinkIcon (type: string) {
switch (type) {
case 'youtube':
return <Youtube className="w-4 h-4" />;
case 'twitter':
return <Twitter className="w-4 h-4" />;
case 'tiktok':
return <Tiktok className="w-4 h-4" />;
case 'facebook':
return <Facebook className="w-4 h-4" />;
case 'instagram':
return <Instagram className="w-4 h-4" />;
case 'linkedin':
return <LinkedInIcon className="w-4 h-4" />;
default:
return <Website className="w-4 h-4" />;
}
}

View File

@ -0,0 +1,3 @@
import {lazy} from 'react';
export const AsTemplate = lazy(() => import('./AsTemplate'));

View File

@ -0,0 +1,65 @@
import { TemplateSummary } from '@/application/template.type';
import { NormalModal } from '@/components/_shared/modal';
import { useLoadCategories } from '@/components/as-template/hooks';
import CategoryTemplates from '@/components/as-template/related-template/CategoryTemplates';
import { Button, CircularProgress } from '@mui/material';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as AddIcon } from '@/assets/add.svg';
function AddRelatedTemplates ({
selectedTemplateIds,
onChange,
updateTemplate,
}: {
selectedTemplateIds: string[]
onChange: (value: string[]) => void;
updateTemplate: (template: TemplateSummary) => void;
}) {
const {
categories,
loadCategories,
loading,
} = useLoadCategories();
const [openModal, setOpenModal] = React.useState(false);
const { t } = useTranslation();
return (
<>
<Button onClick={() => {
void loadCategories();
setOpenModal(true);
}} className={'w-full'} color={'inherit'} startIcon={<AddIcon />} variant={'outlined'}
>{t('template.addRelatedTemplate')}</Button>
{openModal &&
<NormalModal
title={<div className={'text-left'}>{t('template.addRelatedTemplate')}</div>}
open={openModal}
onClose={() => setOpenModal(false)}
onCancel={() => setOpenModal(false)}
onOk={() => {
setOpenModal(false);
}}
fullWidth={true}
>
<div className={'flex flex-col justify-center gap-4'}>
{loading ?
<CircularProgress /> :
categories.map(item =>
(<CategoryTemplates
key={item.id}
category={item}
updateTemplate={updateTemplate}
selectedTemplateIds={selectedTemplateIds}
onChange={onChange}
/>),
)
}
</div>
</NormalModal>}
</>
);
}
export default AddRelatedTemplates;

View File

@ -0,0 +1,76 @@
import { TemplateSummary } from '@/application/template.type';
import { RichTooltip } from '@/components/_shared/popover';
import { Checkbox } from '@mui/material';
import { debounce } from 'lodash-es';
import React, { useMemo } from 'react';
function CategoryTemplateItem ({
onChange,
isSelected,
template,
}: {
template: TemplateSummary;
isSelected: boolean;
onChange: (checked: boolean) => void;
}) {
const [open, setOpen] = React.useState(false);
const debounceOpen = useMemo(() => {
return debounce(() => {
setOpen(true);
}, 1000);
}, []);
const debounceClose = useMemo(() => {
return debounce(() => {
debounceOpen.cancel();
setOpen(false);
}, 100);
}, [debounceOpen]);
const iframePreview = useMemo(() => {
const url = new URL(template.view_url);
url.searchParams.set('theme', 'light');
url.searchParams.set('template', 'true');
url.searchParams.set('thumbnail', 'true');
return <iframe
onMouseLeave={debounceClose}
onMouseEnter={() => {
debounceClose.cancel();
debounceOpen();
}}
loading={'lazy'}
src={url.toString()}
className={'aspect-video h-[230px]'}
/>;
}, [template, debounceOpen, debounceClose]);
return (
<div
key={template.view_id}
className={`template-item ${isSelected ? 'selected' : ''}`}
>
<div className={'flex flex-col gap-1 overflow-hidden'}>
<div className={'flex items-center overflow-hidden gap-2 w-full '}
>
<Checkbox checked={isSelected} onChange={(e) => onChange(e.target.checked)} />
<RichTooltip placement={'bottom-start'} content={iframePreview} open={open} onClose={debounceClose}>
<div
onMouseEnter={() => {
debounceClose.cancel();
debounceOpen();
}}
onMouseLeave={debounceClose}
className={'flex-1 hover:underline cursor-pointer whitespace-nowrap truncate font-medium '}
>{template.name}</div>
</RichTooltip>
</div>
</div>
</div>
);
}
export default React.memo(CategoryTemplateItem);

View File

@ -0,0 +1,124 @@
import { TemplateCategory, TemplateSummary } from '@/application/template.type';
import { useLoadCategoryTemplates } from '@/components/as-template/hooks';
import { CategoryIcon } from '@/components/as-template/icons';
import CategoryTemplateItem from '@/components/as-template/related-template/CategoryTemplateItem';
import { debounce } from 'lodash-es';
import React, { useEffect, useMemo } from 'react';
import { Button, Collapse, OutlinedInput, Skeleton } from '@mui/material';
import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg';
import { ReactComponent as SearchIcon } from '@/assets/search.svg';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
function CategoryTemplates ({
category,
selectedTemplateIds,
onChange,
updateTemplate,
}: {
category: TemplateCategory;
selectedTemplateIds: string[];
onChange: (value: string[]) => void;
updateTemplate: (template: TemplateSummary) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const [searchText, setSearchText] = React.useState('');
const {
templates,
loading,
loadCategoryTemplates,
} = useLoadCategoryTemplates();
const [searchParams] = useSearchParams();
const filteredTemplates = useMemo(() => {
const currentTemplateViewId = searchParams.get('viewId');
return templates.filter((template) => template.view_id !== currentTemplateViewId);
}, [templates, searchParams]);
const handleClick = () => {
setOpen(prev => {
if (!prev) {
void loadCategoryTemplates(category.id);
}
return !prev;
});
};
const debounceSearch = useMemo(() => {
return debounce((id: string, searchText: string) => {
void loadCategoryTemplates(id, searchText);
}, 300);
}, [loadCategoryTemplates]);
useEffect(() => {
filteredTemplates.forEach((template) => {
updateTemplate(template);
});
}, [filteredTemplates, updateTemplate]);
useEffect(() => {
return () => {
debounceSearch.cancel();
};
}, [debounceSearch]);
return (
<div className={'flex flex-col gap-2'}>
<Button onClick={handleClick} className={'text-text-caption font-medium justify-between flex items-center gap-2'}>
<CategoryIcon icon={category.icon} />
<div className={'flex-1 text-left'}>{category.name}</div>
{open ? <RightIcon className={'w-4 h-4 transform rotate-90'} /> : <RightIcon className={'w-4 h-4'} />}
</Button>
<Collapse in={open} timeout="auto" unmountOnExit>
<OutlinedInput
size={'small'}
fullWidth
value={searchText}
className={'gap-2 mb-2'}
startAdornment={<SearchIcon />}
placeholder={t('template.searchInCategory', {
category: category.name,
})}
onChange={(e) => {
setSearchText(e.target.value);
debounceSearch(category.id, e.target.value);
}}
/>
{loading ? (<div className={'flex gap-2 flex-col w-full'}>
<Skeleton variant={'rectangular'} height={40} />
<Skeleton variant={'rectangular'} height={40} />
<Skeleton variant={'rectangular'} height={40} />
</div>) : (
<div className={'flex flex-col'}>
{filteredTemplates.map((template) => {
const isSelected = selectedTemplateIds.includes(template.view_id);
return (
<CategoryTemplateItem
key={template.view_id}
template={template}
isSelected={isSelected}
onChange={(checked) => {
if (checked) {
onChange([...selectedTemplateIds, template.view_id]);
} else {
onChange(selectedTemplateIds.filter((id) => id !== template.view_id));
}
}}
/>
);
})}
</div>
)}
</Collapse>
</div>
);
}
export default React.memo(CategoryTemplates);

View File

@ -0,0 +1,75 @@
import { TemplateSummary } from '@/application/template.type';
import AddRelatedTemplates from '@/components/as-template/related-template/AddRelatedTemplates';
import TemplateItem from '@/components/as-template/related-template/TemplateItem';
import { InputLabel, Grid, IconButton, Tooltip } from '@mui/material';
import React, { useCallback, useEffect, useMemo, useRef, forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as DeleteIcon } from '@/assets/trash.svg';
function RelatedTemplates ({ value = [], onChange, defaultRelatedTemplates }: {
value?: string[];
onChange: (value: string[]) => void;
defaultRelatedTemplates?: TemplateSummary[];
}, ref: React.ForwardedRef<HTMLDivElement>) {
const { t } = useTranslation();
const relatedTemplatesRef = useRef<Map<string, TemplateSummary>>(new Map());
useEffect(() => {
defaultRelatedTemplates?.forEach((template) => {
relatedTemplatesRef.current.set(template.view_id, template);
});
}, [defaultRelatedTemplates]);
const updateTemplate = useCallback((template: TemplateSummary) => {
relatedTemplatesRef.current.set(template.view_id, template);
}, []);
const renderTemplates = useMemo(() => {
return value.map((id) => {
const template = relatedTemplatesRef.current.get(id) || defaultRelatedTemplates?.find((t) => t.view_id === id);
if (!template) return null;
const currentCategory = template.categories[0];
return (
<Grid key={template.view_id} item sm={12} md={6}>
<div className={'template-item relative'}>
<TemplateItem template={template} category={currentCategory} />
<Tooltip title={t('template.removeRelatedTemplate')} placement={'top'}>
<IconButton
className={'delete-icon absolute right-2 top-2 bg-bg-body hover:text-function-error'}
onClick={() => onChange(value.filter((v) => v !== id))}
>
<DeleteIcon className={'w-5 h-5'} />
</IconButton>
</Tooltip>
</div>
</Grid>
);
});
}, [value, t, onChange, defaultRelatedTemplates]);
return (
<div ref={ref} className={'flex flex-col gap-4'}>
<InputLabel>{t('template.relatedTemplates')}</InputLabel>
<AddRelatedTemplates selectedTemplateIds={value} updateTemplate={updateTemplate} onChange={onChange} />
<Grid
container
className={'templates'}
rowSpacing={{
xs: 2,
sm: 4,
}}
columns={{ xs: 4, sm: 8, md: 12 }}
columnSpacing={{ sm: 2, md: 3 }}
>
{renderTemplates}
</Grid>
</div>
);
}
export default forwardRef(RelatedTemplates);

View File

@ -0,0 +1,45 @@
import { TemplateSummary, TemplateCategory } from '@/application/template.type';
import CreatorAvatar from '@/components/as-template/creator/CreatorAvatar';
import React, { useMemo } from 'react';
const url = import.meta.env.AF_BASE_URL?.includes('test') ? 'https://test.appflowy.io' : 'https://appflowy.io';
function TemplateItem ({ template, category }: { template: TemplateSummary; category: TemplateCategory }) {
const iframeUrl = useMemo(() => {
const url = new URL(template.view_url);
url.searchParams.set('theme', 'light');
url.searchParams.set('template', 'true');
url.searchParams.set('thumbnail', 'true');
return url.toString();
}, [template.view_url]);
return (
<>
<a
href={`${url}/template-center/${category.id}/${template.view_id}`}
className={'relative rounded-[16px] pt-4 px-4 h-[230px] w-full overflow-hidden'}
target={'_blank'}
style={{
backgroundColor: category?.bg_color,
}}
>
<iframe loading={'lazy'} className={'w-full h-full'} src={iframeUrl} />
</a>
<div className={'template-info'}>
<div className={'template-creator'}>
<div className={'avatar'}>
<CreatorAvatar size={40} src={template.creator.avatar_url} name={template.creator.name} />
</div>
<div className={'right-info'}>
<div className={'template-name'}>{template.name}</div>
<div className={'creator-name'}>by {template.creator.name}</div>
</div>
</div>
<div className={'template-desc'}>{template.description}</div>
</div>
</>
);
}
export default React.memo(TemplateItem);

View File

@ -0,0 +1,45 @@
.template-category {
svg {
@apply w-[20px] h-[20px];
}
}
.templates {
.template-item {
@apply flex flex-col gap-5;
.template-preview {
@apply rounded-[16px] pt-4 px-4 h-[230px] w-full;
iframe {
@apply w-full h-full bg-white;
pointer-events: none;
}
}
.template-info {
@apply flex flex-col gap-4;
.template-creator {
@apply flex gap-3 items-center;
.avatar {
@apply w-[40px] h-[40px] rounded-full border overflow-hidden;
}
.right-info {
@apply flex flex-col gap-1;
.template-name {
@apply text-[14px] font-medium;
}
.creator-name {
@apply text-[14px] opacity-50;
}
}
}
.template-desc {
@apply text-sm whitespace-pre-wrap break-words max-h-[60px] overflow-hidden pb-[30px];
}
}
}
}

View File

@ -29,7 +29,7 @@ export interface Database2Props {
iidIndex: string;
}
function Database({
function Database ({
doc,
getViewRowsMap,
navigateToView,
@ -91,7 +91,7 @@ function Database({
{rowId ? (
<DatabaseRow rowId={rowId} />
) : (
<div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden'>
<div className="appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden">
<DatabaseViews
visibleViewIds={visibleViewIds}
iidIndex={iidIndex}

View File

@ -48,6 +48,7 @@ $today-highlight-bg: transparent;
}
.rbc-day-bg + .rbc-day-bg {
border-left-color: var(--line-divider);
}
@ -70,6 +71,13 @@ $today-highlight-bg: transparent;
}
}
html[thumbnail="true"] {
.rbc-month-row, .rbc-month-header {
width: 100% !important;
min-width: 100% !important;
}
}
.rbc-month-row .rbc-row-bg {
.rbc-off-range-bg {
background-color: transparent;

View File

@ -4,7 +4,7 @@ import { ViewMeta } from '@/application/db/tables/view_metas';
import { DatabaseActions } from '@/components/database/components/conditions';
import { Tooltip } from '@mui/material';
import { forwardRef, FunctionComponent, SVGProps, useContext, useEffect, useMemo, useState } from 'react';
import { ViewTabs, ViewTab } from './ViewTabs';
import { ViewTabs, ViewTab } from 'src/components/_shared/tabs/ViewTabs';
import { useTranslation } from 'react-i18next';
import { ReactComponent as GridSvg } from '@/assets/grid.svg';
@ -74,11 +74,11 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
style={{
width: showActions ? 'calc(100% - 120px)' : '100%',
}}
className='flex items-center '
className="flex items-center "
>
<ViewTabs
scrollButtons={false}
variant='scrollable'
variant="scrollable"
allowScrollButtonsMobile
value={selectedViewId}
onChange={handleChange}
@ -96,8 +96,8 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
key={viewId}
data-testid={`view-tab-${viewId}`}
icon={<Icon className={'h-4 w-4'} />}
iconPosition='start'
color='inherit'
iconPosition="start"
color="inherit"
label={
<Tooltip title={name} enterDelay={1000} enterNextDelay={1000} placement={'right'}>
<span className={'max-w-[120px] truncate'}>{name || t('grid.title.placeholder')}</span>
@ -112,5 +112,5 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
{showActions ? <DatabaseActions /> : null}
</div>
);
}
},
);

View File

@ -1,2 +1,2 @@
export * from './DatabaseTabs';
export * from './ViewTabs';
export * from 'src/components/_shared/tabs/ViewTabs';

View File

@ -11,9 +11,18 @@ export interface DocumentProps {
loadView?: LoadView;
getViewRowsMap?: GetViewRowsMap;
viewMeta: ViewMetaProps;
isTemplateThumb?: boolean;
}
export const Document = ({ doc, loadView, navigateToView, loadViewMeta, getViewRowsMap, viewMeta }: DocumentProps) => {
export const Document = ({
doc,
loadView,
navigateToView,
loadViewMeta,
getViewRowsMap,
viewMeta,
isTemplateThumb,
}: DocumentProps) => {
return (
<div className={'mb-16 flex h-full w-full flex-col items-center justify-center'}>
<ViewMetaPreview {...viewMeta} />
@ -24,11 +33,13 @@ export const Document = ({ doc, loadView, navigateToView, loadViewMeta, getViewR
loadViewMeta={loadViewMeta}
navigateToView={navigateToView}
getViewRowsMap={getViewRowsMap}
readSummary={isTemplateThumb}
doc={doc}
readOnly={true}
/>
</div>
</Suspense>
</div>
);
};

View File

@ -10,8 +10,9 @@ import * as Y from 'yjs';
const defaultInitialValue: Descendant[] = [];
function CollaborativeEditor({ doc }: { doc: Y.Doc }) {
function CollaborativeEditor ({ doc }: { doc: Y.Doc }) {
const context = useEditorContext();
const readSummary = context.readSummary;
// if readOnly, collabOrigin is Local, otherwise RemoteSync
const localOrigin = context.readOnly ? CollabOrigin.Local : CollabOrigin.LocalSync;
const editor = useMemo(
@ -21,10 +22,11 @@ function CollaborativeEditor({ doc }: { doc: Y.Doc }) {
withReact(
withYjs(createEditor(), doc, {
localOrigin,
})
)
readSummary,
}),
),
) as YjsEditor),
[doc, localOrigin]
[readSummary, doc, localOrigin],
);
const [, setIsConnected] = useState(false);

View File

@ -22,6 +22,7 @@ export interface EditorContextState {
loadViewMeta?: LoadViewMeta;
loadView?: LoadView;
getViewRowsMap?: GetViewRowsMap;
readSummary?: boolean;
}
export const EditorContext = createContext<EditorContextState>({
@ -34,6 +35,6 @@ export const EditorContextProvider = ({ children, ...props }: EditorContextState
return <EditorContext.Provider value={props}>{children}</EditorContext.Provider>;
};
export function useEditorContext() {
export function useEditorContext () {
return useContext(EditorContext);
}

View File

@ -1,7 +1,8 @@
import { CommentUser, GlobalComment, Reaction } from '@/application/comment.type';
import { PublishContext } from '@/application/publish';
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import { stringAvatar } from '@/utils/color';
import { isFlagEmoji } from '@/utils/emoji';
import dayjs from 'dayjs';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -30,11 +31,11 @@ export const GlobalCommentContext = React.createContext<{
highLightCommentId: null,
});
export function useGlobalCommentContext() {
export function useGlobalCommentContext () {
return useContext(GlobalCommentContext);
}
export function useLoadReactions() {
export function useLoadReactions () {
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const service = useContext(AFConfigContext)?.service;
const currentUser = useContext(AFConfigContext)?.currentUser;
@ -133,13 +134,13 @@ export function useLoadReactions() {
console.error(e);
}
},
[currentUser, service, viewId]
[currentUser, service, viewId],
);
return { reactions, toggleReaction };
}
export function useLoadComments() {
export function useLoadComments () {
const viewId = useContext(PublishContext)?.viewMeta?.view_id;
const service = useContext(AFConfigContext)?.service;
@ -168,17 +169,23 @@ export function useLoadComments() {
return { comments, loading, reload: fetchComments };
}
export function getAvatar(comment: GlobalComment) {
export function getAvatar (comment: GlobalComment) {
if (comment.user?.avatarUrl) {
const isFlag = isFlagEmoji(comment.user.avatarUrl);
return {
children: <span>{comment.user.avatarUrl}</span>,
children: <span className={isFlag ? 'icon' : ''}>{comment.user.avatarUrl}</span>,
sx: {
bgcolor: 'transparent',
color: 'var(--text-title)',
},
};
}
return stringAvatar(comment.user?.name || '');
}
export function useCommentRender(comment: GlobalComment) {
export function useCommentRender (comment: GlobalComment) {
const { t } = useTranslation();
const avatar = useMemo(() => {
return getAvatar(comment);

View File

@ -4,7 +4,7 @@ import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import smoothScrollIntoViewIfNeeded from 'smooth-scroll-into-view-if-needed';
function ReplyComment({ commentId }: { commentId?: string | null }) {
function ReplyComment ({ commentId }: { commentId?: string | null }) {
const { getComment, setHighLightCommentId } = useGlobalCommentContext();
const { t } = useTranslation();
const replyComment = useMemo(() => {
@ -31,7 +31,7 @@ function ReplyComment({ commentId }: { commentId?: string | null }) {
if (!replyComment) return null;
return (
<div className={'flex items-center gap-1 text-sm text-text-caption'}>
<Avatar {...avatar} className={'h-4 w-4 text-xs'} />
<Avatar {...avatar} className={`h-4 w-4 text-xs`} />
<div className={'whitespace-nowrap text-xs font-medium text-content-blue-400'}>@{replyComment.user?.name}</div>
<div onClick={handleClick} className={'cursor-pointer truncate px-1 hover:text-text-title'}>
{replyComment.isDeleted ? (

View File

@ -3,7 +3,7 @@ import { PublishContext } from '@/application/publish';
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import { Popover } from '@/components/_shared/popover';
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { Button, IconButton, Tooltip, TooltipProps } from '@mui/material';
import React, { memo, useCallback, useContext, useMemo } from 'react';

View File

@ -3,7 +3,7 @@ import { EmojiPicker } from '@/components/_shared/emoji-picker';
import { EMOJI_SIZE, PER_ROW_EMOJI_COUNT } from '@/components/_shared/emoji-picker/const';
import { Popover } from '@/components/_shared/popover';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import { ReactComponent as AddReactionRounded } from '@/assets/add_reaction.svg';
import { IconButton, Tooltip } from '@mui/material';

View File

@ -1,6 +1,6 @@
import { PublishContext } from '@/application/publish';
import { notify } from '@/components/_shared/notify';
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';
import ReplyComment from '@/components/global-comment/ReplyComment';

View File

@ -5,7 +5,7 @@ import React, { useEffect, useRef, useState } from 'react';
import AddComment from './AddComment';
import { Portal } from '@mui/material';
export function AddCommentWrapper() {
export function AddCommentWrapper () {
const { replyCommentId } = useGlobalCommentContext();
const addCommentRef = useRef<HTMLDivElement>(null);
const [showFixedAddComment, setShowFixedAddComment] = useState(false);
@ -44,7 +44,7 @@ export function AddCommentWrapper() {
return (
<>
<div className={'my-2'} id='addComment' ref={addCommentRef}>
<div className={'my-2'} id="addComment" ref={addCommentRef}>
<AddComment
content={content}
setContent={setContent}

View File

@ -14,7 +14,7 @@ interface CommentProps {
const MAX_HEIGHT = 320;
function Comment({ comment }: CommentProps) {
function Comment ({ comment }: CommentProps) {
const { avatar, time, timeFormat } = useCommentRender(comment);
const { t } = useTranslation();
const ref = React.useRef<HTMLDivElement>(null);
@ -41,7 +41,7 @@ function Comment({ comment }: CommentProps) {
<div className={'comment flex flex-col gap-2'} ref={ref}>
<div className={'flex items-center gap-2'}>
<div className={'flex items-center gap-4'}>
<Avatar {...avatar} className={'h-8 w-8'} />
<Avatar {...avatar} className={`h-8 w-8`} />
<div className={'font-semibold'}>{comment.user?.name}</div>
</div>
<Tooltip title={timeFormat} enterNextDelay={500} enterDelay={1000} placement={'top-start'}>

View File

@ -1,4 +1,4 @@
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import CommentActions from '@/components/global-comment/actions/CommentActions';
import Comment from './Comment';
import { useGlobalCommentContext } from '@/components/global-comment/GlobalComment.hooks';

View File

@ -1,5 +1,5 @@
import { Reaction as ReactionType } from '@/application/comment.type';
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import { isFlagEmoji } from '@/utils/emoji';
import { getPlatform } from '@/utils/platform';
import { Tooltip } from '@mui/material';

View File

@ -1,4 +1,4 @@
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import { CircularProgress } from '@mui/material';
import { useContext, useEffect, useState } from 'react';

View File

@ -1,5 +1,5 @@
import { notify } from '@/components/_shared/notify';
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import { Button } from '@mui/material';
import React, { useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

View File

@ -1,5 +1,5 @@
import { notify } from '@/components/_shared/notify';
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import { Button, CircularProgress, OutlinedInput } from '@mui/material';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';

View File

@ -11,7 +11,7 @@ export interface CollabViewProps {
doc?: YDoc;
}
function CollabView({ doc }: CollabViewProps) {
function CollabView ({ doc }: CollabViewProps) {
const visibleViewIds = usePublishContext()?.viewMeta?.visible_view_ids;
const { viewId, layout, icon, cover, layoutClassName, style, name } = useViewMeta();
const View = useMemo(() => {
@ -32,25 +32,30 @@ function CollabView({ doc }: CollabViewProps) {
getViewRowsMap?: GetViewRowsMap;
loadView?: LoadView;
viewMeta: ViewMetaProps;
isTemplateThumb?: boolean;
}>;
const navigateToView = usePublishContext()?.toView;
const loadViewMeta = usePublishContext()?.loadViewMeta;
const getViewRowsMap = usePublishContext()?.getViewRowsMap;
const loadView = usePublishContext()?.loadView;
const isTemplateThumb = usePublishContext()?.isTemplateThumb;
if (!doc || !View) {
return <ComponentLoading />;
}
return (
<div style={style} className={`relative w-full flex-1 ${layoutClassName}`}>
<div style={style}
className={`relative w-full flex-1 ${isTemplateThumb ? 'flex justify-center' : ''} ${layoutClassName}`}
>
<View
doc={doc}
loadViewMeta={loadViewMeta}
getViewRowsMap={getViewRowsMap}
navigateToView={navigateToView}
loadView={loadView}
isTemplateThumb={isTemplateThumb}
viewMeta={{
icon,
cover,

View File

@ -1,4 +1,5 @@
import { GetViewRowsMap, LoadView, LoadViewMeta, YDoc } from '@/application/collab.type';
import { usePublishContext } from '@/application/publish';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import { Database } from '@/components/database';
import DatabaseHeader from '@/components/database/components/header/DatabaseHeader';
@ -15,10 +16,11 @@ export interface DatabaseProps {
viewMeta: ViewMetaProps;
}
function DatabaseView({ viewMeta, ...props }: DatabaseProps) {
function DatabaseView ({ viewMeta, ...props }: DatabaseProps) {
const [search, setSearch] = useSearchParams();
const visibleViewIds = useMemo(() => viewMeta.visibleViewIds || [], [viewMeta]);
const isTemplateThumb = usePublishContext()?.isTemplateThumb;
const iidIndex = viewMeta.viewId;
const viewId = useMemo(() => {
return search.get('v') || iidIndex;
@ -28,14 +30,14 @@ function DatabaseView({ viewMeta, ...props }: DatabaseProps) {
(viewId: string) => {
setSearch({ v: viewId });
},
[setSearch]
[setSearch],
);
const handleNavigateToRow = useCallback(
(rowId: string) => {
setSearch({ r: rowId });
},
[setSearch]
[setSearch],
);
const rowId = search.get('r') || undefined;
@ -45,7 +47,8 @@ function DatabaseView({ viewMeta, ...props }: DatabaseProps) {
return (
<div
style={{
minHeight: 'calc(100vh - 48px)',
minHeight: 'calc(100% - 48px)',
maxWidth: isTemplateThumb ? '964px' : undefined,
}}
className={'relative flex h-full w-full flex-col px-16 max-md:px-4'}
>

View File

@ -2,14 +2,14 @@ import { YDoc } from '@/application/collab.type';
import { PublishProvider } from '@/application/publish';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import { AFScroller } from '@/components/_shared/scroller';
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import { GlobalCommentProvider } from '@/components/global-comment';
import CollabView from '@/components/publish/CollabView';
import { OutlineDrawer } from '@/components/publish/outline';
import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys';
import React, { Suspense, useCallback, useContext, useEffect, useState } from 'react';
import { PublishViewHeader } from '@/components/publish/header';
import NotFound from '@/components/error/NotFound';
import { useSearchParams } from 'react-router-dom';
export interface PublishViewProps {
namespace: string;
@ -18,7 +18,7 @@ export interface PublishViewProps {
const drawerWidth = 268;
export function PublishView({ namespace, publishName }: PublishViewProps) {
export function PublishView ({ namespace, publishName }: PublishViewProps) {
const [doc, setDoc] = useState<YDoc | undefined>();
const [notFound, setNotFound] = useState<boolean>(false);
@ -44,49 +44,50 @@ export function PublishView({ namespace, publishName }: PublishViewProps) {
const [open, setOpen] = useState(false);
const onKeyDown = useCallback((e: KeyboardEvent) => {
switch (true) {
case createHotkey(HOT_KEY_NAME.TOGGLE_SIDEBAR)(e):
e.preventDefault();
// setOpen((prev) => !prev);
break;
default:
break;
}
}, []);
const [search] = useSearchParams();
const isTemplate = search.get('template') === 'true';
const isTemplateThumb = isTemplate && search.get('thumbnail') === 'true';
useEffect(() => {
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
};
}, [onKeyDown]);
if (!isTemplateThumb) return;
document.documentElement.setAttribute('thumbnail', 'true');
}, [isTemplateThumb]);
if (notFound && !doc) {
return <NotFound />;
}
return (
<PublishProvider namespace={namespace} publishName={publishName}>
<div className={'h-screen w-screen'}>
<PublishProvider isTemplateThumb={isTemplateThumb} namespace={namespace} publishName={publishName}>
<div className={'h-screen w-screen'} style={isTemplateThumb ? {
pointerEvents: 'none',
transform: 'scale(0.333)',
transformOrigin: '0 0',
width: '300vw',
height: '400vh',
overflow: 'hidden',
} : undefined}
>
<AFScroller
overflowXHidden
overflowYHidden={isTemplateThumb}
style={{
transform: open ? `translateX(${drawerWidth}px)` : 'none',
width: open ? `calc(100% - ${drawerWidth}px)` : '100%',
transition: 'width 0.2s ease-in-out, transform 0.2s ease-in-out',
}}
className={'appflowy-layout appflowy-scroll-container'}
className={'appflowy-layout appflowy-scroll-container h-full'}
>
<PublishViewHeader
{!isTemplate && <PublishViewHeader
onOpenDrawer={() => {
setOpen(true);
}}
openDrawer={open}
/>
/>}
<CollabView doc={doc} />
{doc && (
{doc && !isTemplate && (
<Suspense fallback={<ComponentLoading />}>
<GlobalCommentProvider />
</Suspense>

View File

@ -1,7 +1,8 @@
import { invalidToken } from '@/application/session/token';
import { Popover } from '@/components/_shared/popover';
import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/app.hooks';
import { ThemeModeContext } from '@/components/app/useAppThemeMode';
import AsTemplateButton from '@/components/as-template/AsTemplateButton';
import { openUrl } from '@/utils/url';
import { IconButton } from '@mui/material';
import React, { useCallback, useContext, useMemo } from 'react';
@ -16,7 +17,7 @@ import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg';
import { useNavigate } from 'react-router-dom';
function MoreActions() {
function MoreActions () {
const { isDark, setDark } = useContext(ThemeModeContext) || {};
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
@ -91,6 +92,8 @@ function MoreActions() {
onClose={handleClose}
>
<div className={'flex w-[240px] flex-col gap-2 px-2 py-2'}>
<AsTemplateButton />
{actions.map((action, index) => (
<button
onClick={() => {
@ -106,6 +109,7 @@ function MoreActions() {
<span>{action.label}</span>
</button>
))}
<div
onClick={() => {
window.open('https://appflowy.io', '_blank');

Some files were not shown because too many files have changed in this diff Show More