├── .gitignore ├── LICENSE ├── README.md ├── app ├── details │ ├── layout.tsx │ ├── loading.tsx │ └── page.tsx ├── edittools │ ├── edit │ │ └── page.tsx │ ├── layout.tsx │ ├── loading.tsx │ └── page.tsx ├── error.tsx ├── layout.tsx ├── page.tsx ├── providers.tsx ├── search │ ├── layout.tsx │ ├── loading.tsx │ └── page.tsx ├── settings │ ├── about │ │ ├── layout.tsx │ │ └── page.tsx │ ├── appearence │ │ ├── layout.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── loading.tsx │ └── page.tsx ├── software │ ├── layout.tsx │ ├── loading.tsx │ └── page.tsx ├── style.css └── website │ ├── layout.tsx │ ├── loading.tsx │ └── page.tsx ├── components ├── edit-sidenav.tsx ├── icons.tsx ├── navbar.tsx ├── primitives.ts ├── scroll-wrapper.tsx ├── section-header.tsx ├── tab-icon.tsx ├── theme-switch.tsx ├── tools-detail.tsx └── website-card.tsx ├── config ├── fonts.ts ├── site.ts └── userevent.ts ├── next-env.d.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── logo@0.5x.png ├── next.svg └── vercel.svg ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── capabilities │ └── default.json ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src │ ├── db.rs │ ├── main.rs │ ├── native.rs │ ├── request.rs │ ├── search.rs │ └── source.rs └── tauri.conf.json ├── statics ├── import_tools_config.gif ├── myTools设计文档.drawio ├── screenshoot-5.webp ├── screenshot-1.webp ├── screenshot-2.webp ├── screenshot-3.webp ├── screenshot-4.webp └── tools-export.json ├── styles └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── types └── index.ts └── utils └── github.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src-tauri/gen/* 3 | src-tauri/target/* 4 | .DS_Store 5 | out 6 | .next -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Next UI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # myTools 2 | 3 | > 一个基于 `Next.js` + `Tauri` 构建的工具网站管理工具。 4 | > 5 | 6 | 这个App的目标是能过可持续的为大家分享免费好用的工具网站。对我来说做成一个离线工具相比与一个资源聚合站点有以下几个优势: 7 | 8 | 1. 更加可持续的方式。不需要负担额外的服务器费用; 9 | 2. 可以做更多自定义的功能,更加方便快捷的使用; 10 | 3. 大家都可以分享自己使用的软件或网站 11 | 12 | > 说明:应用包,每周更新一次。 13 | 14 | ### 界面展示 15 | 16 | | ![](./statics//screenshot-1.webp) | ![](./statics//screenshot-2.webp)| 17 | | --- | --- | 18 | | ![](./statics//screenshot-3.webp) | ![](./statics//screenshot-4.webp) | 19 | 20 | ### 开始 21 | 1. 下载[配置文件](./statics//tools-export.json) 22 | 2. 点击窗口右上角的设置(⚙️)按钮,然后按照下图进行操作 23 | 3. ![](./statics//import_tools_config.gif) 24 | 25 | ### 规范 26 | 1. 封面大小:600x400 27 | 2. 预览图大小:1200x800 28 | 3. 图片格式:webp,质量70 29 | 4. 工具说明支持`Markdown`语法,仅支持插入在线图片。 30 | 5. 封面中如果展示软件图片,则图标尺寸为:225x225,背景色推荐色号:`#F7F8FC` 31 | 32 | ### 开发任务 33 | | 任务 | 状态 | 优先级 | 预览| 34 | | --- | --- | --- | --- | 35 | | 支持单个工具的删除,使用contextWindow | ✅ | 1 | ![](./statics/screenshoot-5.webp)| 36 | | 在线工具源的下载和导入 | ✅ | 1 |测试链接:`https://raw.githubusercontent.com/feint123/myTools/main/statics/tools-export.json`| 37 | | 工具源的更新|✅| 2|| 38 | | 工具快捷面板窗口,全局快捷键唤起 | | 2 || 39 | | 完善设置页面 || 2 || 40 | | 页面加载动画使用骨架屏||3|| 41 | | 增加必要的系统菜单和Tray| | 3|| 42 | | 实现快捷指令 ||3|| 43 | | 多语言支持|| 4|| 44 | | windows,Linux兼容性测试|| 4|| 45 | 46 | ### 设计文档 47 | 48 | 参考文件[myTools设计文档.drawio](./statics/myTools设计文档.drawio)(完善中)。请使用`Draw.io`查看。 49 | 50 | ### Bug记录 51 | 1. ~~编辑页面删除工具后,页面某些情况列表数据不会~~更新~~ -------------------------------------------------------------------------------- /app/details/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { ScrollWrapper } from "@/components/scroll-wrapper"; 3 | import { ScrollShadow } from "@nextui-org/react"; 4 | import { Suspense } from "react"; 5 | 6 | export default function AboutLayout({ 7 | children, 8 | }: { 9 | children: React.ReactNode; 10 | }) { 11 | return ( 12 |
13 | 14 |
15 | 16 | {children} 17 | 18 |
19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/details/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@nextui-org/react"; 2 | 3 | export default function Loading() { 4 | // You can add any UI inside Loading, including a Skeleton. 5 | return 6 | } -------------------------------------------------------------------------------- /app/details/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useRouter, useSearchParams } from "next/navigation"; 4 | import ToolsDetail from "@/components/tools-detail"; 5 | 6 | export default function DetailPage() { 7 | 8 | const params = useSearchParams() 9 | const toolId = params.get("toolId"); 10 | return ( 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /app/edittools/edit/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | 4 | import { Button, Input, Select, SelectItem, Switch, Textarea } from "@nextui-org/react"; 5 | import { useEffect, useState } from "react"; 6 | import { AiOutlineAppstore, AiOutlineAudio, AiOutlineBug, AiOutlineCloud, AiOutlineCode, AiOutlineFileWord, AiOutlineInbox, AiOutlinePicture, AiOutlineRobot, AiOutlineVideoCamera } from "react-icons/ai"; 7 | import { ConfigProvider, theme, Upload, UploadProps } from "antd"; 8 | 9 | import { RcFile, UploadFile } from "antd/es/upload"; 10 | import { ToolsItem } from "@/app/settings/page"; 11 | import { invoke } from "@tauri-apps/api/core"; 12 | import { getCurrent } from "@tauri-apps/api/webviewWindow"; 13 | import { listen, TauriEvent, UnlistenFn } from "@tauri-apps/api/event"; 14 | import { useRouter, useSearchParams } from "next/navigation"; 15 | import { boldCommand, imageCommand, italicCommand, linkCommand, useTextAreaMarkdownEditor } from "react-headless-mde"; 16 | import { Menu, MenuItem } from "@tauri-apps/api/menu"; 17 | import Markdown from "react-markdown"; 18 | import remarkGfm from "remark-gfm"; 19 | import "github-markdown-css" 20 | import { motion } from "framer-motion"; 21 | 22 | 23 | 24 | 25 | const getBase64 = (file: RcFile): Promise => 26 | new Promise((resolve, reject) => { 27 | const reader = new FileReader(); 28 | reader.readAsDataURL(file); 29 | reader.onload = () => { 30 | resolve(reader.result as string); 31 | } 32 | reader.onerror = (error) => { 33 | console.log(error) 34 | reject(error); 35 | } 36 | }); 37 | 38 | 39 | 40 | 41 | function isValidUrl(url: string): boolean { 42 | url = url.trim(); 43 | if (url.length == 0) { 44 | return false 45 | } 46 | if (!url.startsWith("http://") || !url.startsWith("https://")) { 47 | url = "https://" + url 48 | } 49 | try { 50 | new URL(url); 51 | return true; 52 | } catch (_) { 53 | return false; 54 | } 55 | } 56 | 57 | 58 | export type SelectType = 'all' | Iterable | undefined 59 | 60 | function FileUploaderWrapper({ children }: { children: React.ReactNode }) { 61 | return ( 62 |
63 | {children} 64 |
65 | ) 66 | } 67 | 68 | 69 | export default function EditToolsEditPage() { 70 | const params = useSearchParams() 71 | const toolId = params.get("toolId") 72 | const categorys = [{ name: "音频", icon: () }, { name: "视频", icon: () }, 73 | { name: "图片", icon: () }, { name: "AI", icon: () }, 74 | { name: "编程", icon: () }, { name: "文档", icon: () }, 75 | { name: "聚合", icon: () }, { name: "其他", icon: () }] 76 | const toolTypes = [{ name: "App", value: "app", icon: () }, { name: "网站", value: "web", icon: () }] 77 | const [coverFileList, setCoverFileList] = useState([]); 78 | const [previewFileList, setPreviewFileList] = useState([]); 79 | const [title, setTitle] = useState("") 80 | const [description, setDescription] = useState("") 81 | const [pressSubmit, setPressSubmit] = useState(false); 82 | const [content, setContent] = useState("") 83 | const [selectToolTypes, setSelectToolTypes] = useState() 84 | const [selectToolCates, setSelectToolCates] = useState() 85 | const [targetUrl, setTargetUrl] = useState("") 86 | const [isSubmitLoading, setIsSubmitLoading] = useState(false); 87 | const [antTheme, setAntdTheme] = useState("light"); 88 | 89 | const router = useRouter() 90 | useEffect(() => { 91 | getCurrent().theme().then(theme => { 92 | setAntdTheme(theme as string) 93 | }) 94 | listen(TauriEvent.WINDOW_THEME_CHANGED, (event) => { 95 | if (typeof event.payload === "string") { 96 | setAntdTheme(event.payload) 97 | } 98 | }); 99 | }, [setAntdTheme]) 100 | 101 | const submitTools = async () => { 102 | setPressSubmit(true) 103 | if (title.trim().length == 0 || content.trim().length == 0 104 | || description.trim().length == 0 || targetUrl.trim().length == 0 105 | || selectToolTypes == undefined || selectToolCates == undefined || !isValidUrl(targetUrl)) { 106 | return 107 | } 108 | setIsSubmitLoading(true) 109 | let coverUrl = "" 110 | let file = coverFileList[0] 111 | if (file && !file.url && file.originFileObj) { 112 | coverUrl = await getBase64(file.originFileObj) as string; 113 | } else if (file) { 114 | coverUrl = file.url ?? "" 115 | } 116 | let previewUrls: string[] = [] 117 | for (let i = 0; i < previewFileList.length; i++) { 118 | let previewFile = previewFileList[i] 119 | let url = "" 120 | if (previewFile && previewFile.originFileObj && !previewFile.url) { 121 | url = await getBase64(previewFile.originFileObj) as string; 122 | } else if (previewFile) { 123 | url = previewFile.url ?? "" 124 | } 125 | previewUrls.push(url) 126 | } 127 | let targetUrlC = targetUrl.trim() 128 | if (targetUrlC.startsWith("http://")) { 129 | targetUrlC.replace("http://", "https://") 130 | } else if (!targetUrlC.includes("https://")) { 131 | targetUrlC = "https://" + targetUrlC; 132 | } 133 | const toolsItem: ToolsItem = { 134 | id: 0, 135 | title: title.trim(), 136 | description: description.trim(), 137 | target_url: targetUrlC, 138 | categorys: Array.from(selectToolCates).map(item => item.toString()), 139 | content: content.trim(), 140 | cover_image_url: coverUrl, 141 | preview_image_url: previewUrls, 142 | tool_type: Array.from(selectToolTypes).map(item => item.toString())[0], 143 | author: "hello", 144 | tools_source_id: "", 145 | } 146 | 147 | 148 | try { 149 | let id = await invoke("save_local_source_item", { sourceItem: toolsItem }); 150 | setIsSubmitLoading(false) 151 | router.push(`/edittools?toolId=${id}`) 152 | } catch (error) { 153 | console.log(error) 154 | } 155 | } 156 | 157 | useEffect(() => { 158 | if (toolId) { 159 | invoke("get_source_item_by_id", { itemId: Number.parseInt(toolId) }) 160 | .then((result: ToolsItem[]) => { 161 | if (result && result.length > 0) { 162 | setTitle(result[0].title ?? "") 163 | setDescription(result[0].description ?? "") 164 | setContent(result[0].content ?? "") 165 | setTargetUrl((result[0].target_url ?? "").replace("https://", "")) 166 | setSelectToolTypes(Array.of(result[0].tool_type as string)) 167 | setSelectToolCates(result[0].categorys) 168 | setCoverFileList([{ 169 | uid: '0', 170 | name: `image.png`, 171 | status: 'done', 172 | url: result[0].cover_image_url, 173 | }]) 174 | let previewImageUrls = result[0].preview_image_url 175 | let previewImages: UploadFile[] = [] 176 | if (previewImageUrls && previewImageUrls.length > 0) { 177 | for (let i = 0; i < previewImageUrls.length; i++) { 178 | previewImages.push({ 179 | uid: `${i}`, 180 | name: `image-${i}.png`, 181 | status: 'done', 182 | url: previewImageUrls[i], 183 | }) 184 | } 185 | } 186 | setPreviewFileList(previewImages) 187 | } 188 | }).catch(e => { 189 | console.log(e) 190 | }) 191 | } 192 | let uninstalSave: UnlistenFn; 193 | listen("SAVE_TOOLS_INFO", (e) => { 194 | submitTools() 195 | }).then(fn => { 196 | uninstalSave = fn 197 | }) 198 | return () => { 199 | if (uninstalSave) { 200 | uninstalSave() 201 | } 202 | } 203 | }, [setTitle, setDescription, setContent, setTargetUrl, setSelectToolTypes, setSelectToolCates, 204 | setCoverFileList, setPreviewFileList, toolId, submitTools 205 | ]) 206 | 207 | const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => 208 | setCoverFileList(newFileList); 209 | 210 | const handlePreviewChange: UploadProps['onChange'] = ({ fileList: newFileList }) => 211 | setPreviewFileList(newFileList); 212 | 213 | async function createMenu(e: React.MouseEvent) { 214 | e.preventDefault() 215 | 216 | let linkItem = await MenuItem.new({ 217 | text: "添加链接", 218 | accelerator: "CmdOrCtrl+Shift+L", 219 | action: () => { 220 | commandController.executeCommand('link'); 221 | } 222 | }) 223 | 224 | let imageItem = await MenuItem.new({ 225 | text: "插入图片", 226 | accelerator: "CmdOrCtrl+Shift+I", 227 | action: () => { 228 | commandController.executeCommand('image'); 229 | } 230 | }) 231 | let boldItem = await MenuItem.new({ 232 | text: "加粗", 233 | accelerator: "CmdOrCtrl+Shift+B", 234 | action: () => { 235 | commandController.executeCommand('bold'); 236 | } 237 | }) 238 | let menu = await Menu.new({ 239 | items: [linkItem, imageItem, boldItem] 240 | }); 241 | menu.popup() 242 | } 243 | 244 | 245 | 246 | 247 | const { ref, commandController } = useTextAreaMarkdownEditor({ 248 | commandMap: { 249 | bold: boldCommand, 250 | italic: italicCommand, 251 | link: linkCommand, 252 | image: imageCommand, 253 | }, 254 | }); 255 | 256 | return ( 257 | 258 |
259 | {/* */} 260 |
261 |
262 | 265 | 266 |

封面

267 | 268 | 269 | {/* */} 270 | 279 | {coverFileList.length >= 1 ? null : '+ 上传'} 280 | 281 | {/* */} 282 | 283 |

尺寸:600x400;格式:webp

284 |
285 | 288 | https://} 289 | value={targetUrl} onValueChange={setTargetUrl} isInvalid={!isValidUrl(targetUrl) && pressSubmit} 290 | errorMessage="请输入正确的网址" /> 291 | 292 |
293 | 312 | 331 |
332 | 333 |

预览图

334 | 335 | {/* */} 336 | 345 | {coverFileList.length >= 16 ? null : '+ 上传'} 346 | 347 | {/* */} 348 | 349 |

尺寸:1200x800;格式:webp

350 |
351 |
352 |
353 |