mirror of
https://github.com/Lifeforge-app/lifeforge.git
synced 2026-06-28 06:46:24 +00:00
24w05
Former-commit-id: cea35bb1dcea9b93f55cd6ffef2532638b89afb0 [formerly 2ccaba0d145da0dad36b35fa36ffe83379d04f33] [formerly 251a201c53525d768dc2f620dd5ef7e3424a624a [formerly f3a08b9ad7ecb24d8f4e98e97b684d3c9460ce9d]] Former-commit-id: 7c0834702e2640523a5cae9b61f6eaccf60a1f2b [formerly 9b1217b70f540da999ff0cf5ca9ff6bcdc21aca6] Former-commit-id: 3c12c07b25b0b9ccdada4caf2d73c7f384b71c8e
This commit is contained in:
@@ -1,54 +0,0 @@
|
||||
module.exports = {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"standard-with-typescript",
|
||||
"plugin:react/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:tailwindcss/recommended"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"files": [
|
||||
".eslintrc.{js,cjs}"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "script"
|
||||
}
|
||||
}
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"react",
|
||||
"import",
|
||||
"tailwindcss"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/space-before-function-paren": "off",
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"extensions": [
|
||||
".js",
|
||||
".jsx",
|
||||
".ts",
|
||||
".tsx",
|
||||
".d.ts"
|
||||
]
|
||||
},
|
||||
},
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,26 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
database/
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
30
README.md
30
README.md
@@ -1,30 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
17
index.html
17
index.html
@@ -1,17 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" d>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' aria-hidden='true' role='img' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='rgb(20 184 166)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m11.414 10l-7.383 7.418a2.091 2.091 0 0 0 0 2.967a2.11 2.11 0 0 0 2.976 0L14.414 13m3.707 2.293l2.586-2.586a1 1 0 0 0 0-1.414l-7.586-7.586a1 1 0 0 0-1.414 0L9.121 6.293a1 1 0 0 0 0 1.414l7.586 7.586a1 1 0 0 0 1.414 0z'%3E%3C/path%3E%3C/svg%3E"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LifeForge.</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
72
package.json
72
package.json
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"name": "personal-management-system",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.1",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@iconify/collections": "^1.0.375",
|
||||
"@iconify/react": "^4.1.1",
|
||||
"@types/moment": "^2.13.0",
|
||||
"@types/react-custom-scrollbars": "^4.0.12",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@uiw/codemirror-theme-atomone": "^4.21.21",
|
||||
"@uiw/codemirror-theme-material": "^4.21.21",
|
||||
"@uiw/codemirror-theme-nord": "^4.21.21",
|
||||
"@uiw/react-codemirror": "^4.21.21",
|
||||
"@uiw/react-color": "^2.0.5",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"axios": "^1.6.7",
|
||||
"chart.js": "^4.4.0",
|
||||
"cors": "^2.8.5",
|
||||
"daisyui": "^4.4.14",
|
||||
"eslint-plugin-tailwindcss": "^3.13.0",
|
||||
"express": "^4.18.2",
|
||||
"moment": "^2.30.1",
|
||||
"pocketbase": "^0.19.0",
|
||||
"postcss": "^8.4.31",
|
||||
"react": "^18.2.0",
|
||||
"react-activity-calendar": "^2.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-columns": "^1.2.1",
|
||||
"react-custom-scrollbars-2": "^4.5.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-medium-image-zoom": "^5.1.9",
|
||||
"react-router": "^6.20.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-toastify": "^9.1.3",
|
||||
"react-tooltip": "^5.24.0",
|
||||
"tailwindcss": "^3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@types/": "uidotdev/usehooks",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-color": "^3.0.10",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/react-dropzone": "^5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-standard-with-typescript": "^40.0.0",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"eslint-plugin-n": "^15.0.0 || ^16.0.0 ",
|
||||
"eslint-plugin-promise": "^6.0.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"typescript": "*",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/login.jpg
BIN
public/login.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
40
src/App.tsx
40
src/App.tsx
@@ -1,40 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from 'react'
|
||||
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import AppRouter from './Router'
|
||||
import GlobalStateProvider from './providers/GlobalStateProvider'
|
||||
import { ToastContainer } from 'react-toastify'
|
||||
import AuthProvider from './providers/AuthProvider'
|
||||
import PersonalizationProvider from './providers/PersonalizationProvider'
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<GlobalStateProvider>
|
||||
<AuthProvider>
|
||||
<PersonalizationProvider>
|
||||
<div className="relative flex h-[100dvh] w-full overflow-hidden bg-neutral-200/50 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-100">
|
||||
<AppRouter />
|
||||
</div>
|
||||
<ToastContainer
|
||||
position="bottom-center"
|
||||
autoClose={5000}
|
||||
hideProgressBar={true}
|
||||
newestOnTop={false}
|
||||
closeOnClick
|
||||
rtl={false}
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme="colored"
|
||||
/>
|
||||
</PersonalizationProvider>
|
||||
</AuthProvider>
|
||||
</GlobalStateProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import Header from './components/Header'
|
||||
import { Outlet } from 'react-router'
|
||||
|
||||
function MainApplication(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<main className="flex h-full w-full min-w-0 flex-col pb-0 sm:ml-20 lg:ml-0">
|
||||
<Header />
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MainApplication
|
||||
@@ -1,88 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
import React, { useContext, useEffect } from 'react'
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import Dashboard from './modules/Dashboard'
|
||||
import Auth from './auth'
|
||||
import MainApplication from './MainApplication'
|
||||
import { AuthContext } from './providers/AuthProvider'
|
||||
import TodoList from './modules/TodoList'
|
||||
import Calendar from './modules/Calendar'
|
||||
import Projects from './modules/Projects'
|
||||
import Kanban from './modules/Projects/components/Kanban'
|
||||
import NotFound from './components/general/NotFound'
|
||||
import IdeaBox from './modules/IdeaBox'
|
||||
import Ideas from './modules/IdeaBox/components/Ideas'
|
||||
import Snippets from './modules/Snippets'
|
||||
import Resources from './modules/Resources'
|
||||
import CodeTime from './modules/CodeTime'
|
||||
import PomodoroTimer from './modules/PomodoroTimer'
|
||||
import Flashcards from './modules/Flashcards'
|
||||
import CardSet from './modules/Flashcards/components/CardSet'
|
||||
import ReferenceBooks from './modules/ReferenceBooks'
|
||||
import UniversityAnalytics from './modules/UniversityAnalytics'
|
||||
import Changelog from './modules/Changelog'
|
||||
import Personalization from './modules/Personalization'
|
||||
import Notes from './modules/Notes'
|
||||
import NotesCategory from './modules/Notes/Workspace'
|
||||
import NotesSubject from './modules/Notes/Subject'
|
||||
|
||||
function AppRouter(): React.JSX.Element {
|
||||
const { auth, authLoading } = useContext(AuthContext)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) {
|
||||
if (!auth && location.pathname !== '/auth') {
|
||||
navigate('/auth?redirect=' + location.pathname)
|
||||
} else if (auth) {
|
||||
if (location.pathname === '/auth') {
|
||||
if (location.search) {
|
||||
const redirect = new URLSearchParams(location.search).get(
|
||||
'redirect'
|
||||
)
|
||||
if (redirect) {
|
||||
navigate(redirect)
|
||||
} else {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
}
|
||||
} else if (location.pathname === '/') {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [auth, location, authLoading])
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<MainApplication />}>
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="todo-list" element={<TodoList />} />
|
||||
<Route path="calendar" element={<Calendar />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<Kanban />} />
|
||||
<Route path="idea-box" element={<IdeaBox />} />
|
||||
<Route path="idea-box/:id" element={<Ideas />} />
|
||||
<Route path="snippets" element={<Snippets />} />
|
||||
<Route path="snippets/snippet/:id" element={<Snippets />} />
|
||||
<Route path="resources" element={<Resources />} />
|
||||
<Route path="code-time" element={<CodeTime />} />
|
||||
<Route path="pomodoro-timer" element={<PomodoroTimer />} />
|
||||
<Route path="flashcards" element={<Flashcards />} />
|
||||
<Route path="flashcards/:id" element={<CardSet />} />
|
||||
<Route path="reference-books" element={<ReferenceBooks />} />
|
||||
<Route path="notes" element={<Notes />} />
|
||||
<Route path="notes/:workspace" element={<NotesCategory />} />
|
||||
<Route path="notes/:workspace/:subject/*" element={<NotesSubject />} />
|
||||
<Route path="university-analytics" element={<UniversityAnalytics />} />
|
||||
<Route path="personalization" element={<Personalization />} />
|
||||
<Route path="change-log" element={<Changelog />} />
|
||||
</Route>
|
||||
<Route path="auth" element={<Auth />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppRouter
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
|
||||
function AuthHeader(): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-8 mt-32 flex items-center gap-2 whitespace-nowrap text-3xl font-semibold">
|
||||
<Icon icon="tabler:hammer" className="text-5xl text-custom-500" />
|
||||
<div>
|
||||
LifeForge<span className="text-4xl text-custom-500"> .</span>
|
||||
</div>
|
||||
</h1>
|
||||
<h2 className="text-center text-4xl font-semibold tracking-wide sm:text-5xl">
|
||||
Welcome Back!
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-base text-neutral-500 sm:mt-4 sm:text-xl">
|
||||
Sign in to continue tracking your life.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthHeader
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
function AuthSideImage(): React.ReactElement {
|
||||
return (
|
||||
<section className="relative hidden h-full w-1/2 lg:flex">
|
||||
<img src="/login.jpg" alt="Login" className="h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-custom-500 to-custom-600 opacity-30" />
|
||||
<div className="absolute inset-0 bg-neutral-900/50" />
|
||||
<p className="absolute left-1/2 top-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 flex-col text-center text-5xl font-semibold tracking-wide text-neutral-100">
|
||||
<span className="mb-2 text-2xl text-custom-400">
|
||||
One day, You'll leave this world behind
|
||||
</span>
|
||||
So live a life you remember
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthSideImage
|
||||
@@ -1,87 +0,0 @@
|
||||
import React, { type FormEvent, useContext, useState } from 'react'
|
||||
import { AuthContext } from '../../providers/AuthProvider'
|
||||
import { toast } from 'react-toastify'
|
||||
import { AUTH_ERROR_MESSAGES } from '../../constants/auth'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
|
||||
function AuthSignInButton({
|
||||
emailOrUsername,
|
||||
password
|
||||
}: {
|
||||
emailOrUsername: string
|
||||
password: string
|
||||
}): React.ReactElement {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const {
|
||||
auth,
|
||||
authenticate,
|
||||
authWithOauth,
|
||||
loginQuota: { quota, dismissQuota }
|
||||
} = useContext(AuthContext)
|
||||
|
||||
function signIn(): void {
|
||||
if (quota === 0) {
|
||||
dismissQuota()
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
authenticate({ email: emailOrUsername, password })
|
||||
.then(res => {
|
||||
if (!res.startsWith('success')) {
|
||||
toast.error(res)
|
||||
if (res === AUTH_ERROR_MESSAGES.INVALID_CREDENTIALS) {
|
||||
dismissQuota()
|
||||
}
|
||||
} else {
|
||||
toast.success('Welcome back, ' + res.split(' ').slice(1).join(' '))
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(AUTH_ERROR_MESSAGES.UNKNOWN_ERROR)
|
||||
})
|
||||
}
|
||||
|
||||
function signInWithGithub(): void {
|
||||
setLoading(true)
|
||||
authWithOauth('github')
|
||||
.then(res => {
|
||||
if (!res.startsWith('success')) {
|
||||
toast.error(res)
|
||||
} else {
|
||||
toast.success('Welcome back, ' + res.split(' ').slice(1).join(' '))
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(AUTH_ERROR_MESSAGES.UNKNOWN_ERROR)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-col gap-6">
|
||||
<button
|
||||
disabled={
|
||||
emailOrUsername.length === 0 ||
|
||||
password.length === 0 ||
|
||||
loading ||
|
||||
auth
|
||||
}
|
||||
onClick={signIn}
|
||||
className="flex h-[4.6rem] items-center justify-center rounded-lg bg-custom-500 p-6 font-semibold uppercase tracking-widest text-neutral-100 transition-all hover:bg-custom-600 disabled:cursor-not-allowed disabled:bg-custom-700 disabled:text-neutral-200 dark:disabled:bg-custom-900 dark:disabled:text-neutral-400"
|
||||
>
|
||||
{loading ? <Icon icon="svg-spinners:180-ring" /> : 'Sign In'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={signInWithGithub}
|
||||
className="flex items-center justify-center gap-3 rounded-lg bg-neutral-400 p-6 font-semibold uppercase tracking-widest text-neutral-100 transition-all hover:bg-neutral-500 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<Icon icon="tabler:brand-github" className="text-2xl" />
|
||||
Sign In with Github
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthSignInButton
|
||||
@@ -1,53 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
import React, { useState } from 'react'
|
||||
import AuthSideImage from './components/AuthSideImage'
|
||||
import AuthHeader from './components/AuthHeader'
|
||||
import Input from '../components/general/Input'
|
||||
import AuthSignInButton from './components/AuthSignInButtons'
|
||||
|
||||
function Auth(): React.JSX.Element {
|
||||
const [emailOrUsername, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
function updateEmailOrUsername(
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
): void {
|
||||
setEmail(event.target.value)
|
||||
}
|
||||
|
||||
function updatePassword(event: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setPassword(event.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="flex h-full w-full flex-col items-center overflow-y-auto px-12 pb-12 lg:w-1/2">
|
||||
<AuthHeader />
|
||||
<div className="mt-12 flex w-full max-w-md flex-col gap-8">
|
||||
<Input
|
||||
name="Email or Username"
|
||||
placeholder="someone@somemail.com"
|
||||
icon="tabler:user"
|
||||
value={emailOrUsername}
|
||||
updateValue={updateEmailOrUsername}
|
||||
/>
|
||||
<Input
|
||||
name="Password"
|
||||
placeholder="••••••••••••••••"
|
||||
icon="tabler:key"
|
||||
isPassword
|
||||
value={password}
|
||||
updateValue={updatePassword}
|
||||
/>
|
||||
<AuthSignInButton
|
||||
emailOrUsername={emailOrUsername}
|
||||
password={password}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<AuthSideImage />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Auth
|
||||
@@ -1,101 +0,0 @@
|
||||
import React, { Fragment, useContext } from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
import { GlobalStateContext } from '../providers/GlobalStateProvider'
|
||||
import { AuthContext } from '../providers/AuthProvider'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { toast } from 'react-toastify'
|
||||
import MenuItem from './general/HamburgerMenu/MenuItem'
|
||||
|
||||
export default function Header(): React.JSX.Element {
|
||||
const { sidebarExpanded, toggleSidebar } = useContext(GlobalStateContext)
|
||||
const { userData, getAvatarURL, logout } = useContext(AuthContext)
|
||||
|
||||
return (
|
||||
<header className="relative z-[9990] flex flex-col p-8 sm:p-12">
|
||||
<div className="flex w-full items-center justify-between gap-8">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
{!sidebarExpanded && (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="rounded-lg p-4 text-neutral-500 transition-all hover:bg-neutral-200 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Icon icon="tabler:menu" className="text-2xl" />
|
||||
</button>
|
||||
)}
|
||||
<search className="hidden w-full items-center gap-4 rounded-lg bg-neutral-50 p-4 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50 lg:flex">
|
||||
<Icon icon="tabler:search" className="h-5 w-5 text-neutral-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Quick navigate & search ... (Press / to focus)"
|
||||
className="w-full bg-transparent text-neutral-800 placeholder:text-neutral-400 focus:outline-none"
|
||||
/>
|
||||
</search>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="relative rounded-lg p-4 text-neutral-500 transition-all hover:bg-neutral-200/50 hover:text-neutral-800 dark:hover:bg-neutral-800">
|
||||
<Icon icon="tabler:bell" className="text-2xl" />
|
||||
<div className="absolute bottom-4 right-4 h-2 w-2 rounded-full bg-red-500" />
|
||||
</button>
|
||||
<Menu as="div" className="relative text-left">
|
||||
<Menu.Button className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
<img
|
||||
src={getAvatarURL()}
|
||||
alt="avatar"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden flex-col items-start md:flex">
|
||||
<div className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
{userData?.name}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-400">
|
||||
{userData?.email}
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
icon="tabler:chevron-down"
|
||||
className="stroke-[2px] text-neutral-500"
|
||||
/>
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 mt-4 w-56 overflow-hidden rounded-lg bg-neutral-100 shadow-lg focus:outline-none dark:bg-neutral-800">
|
||||
<div className="py-1">
|
||||
<MenuItem
|
||||
onClick={() => {}}
|
||||
icon="tabler:user-cog"
|
||||
text="Account settings"
|
||||
/>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
logout()
|
||||
toast.warning('Logged out successfully!')
|
||||
}}
|
||||
icon="tabler:logout"
|
||||
text="Sign out"
|
||||
/>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
<search className="mt-4 flex w-full items-center gap-4 rounded-lg bg-neutral-50 p-4 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50 lg:hidden">
|
||||
<Icon icon="tabler:search" className="h-5 w-5 text-neutral-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Quick navigate & search ... (Press / to focus)"
|
||||
className="w-full bg-transparent text-neutral-800 placeholder:text-neutral-400 focus:outline-none"
|
||||
/>
|
||||
</search>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
function SidebarDivider(): React.JSX.Element {
|
||||
return (
|
||||
<li className="my-4 h-px shrink-0 bg-neutral-200 dark:bg-neutral-700" />
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarDivider
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, { useContext } from 'react'
|
||||
import { GlobalStateContext } from '../../../providers/GlobalStateProvider'
|
||||
import { Icon } from '@iconify/react'
|
||||
|
||||
function SidebarHeader(): React.JSX.Element {
|
||||
const { sidebarExpanded, toggleSidebar } = useContext(GlobalStateContext)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-36 w-full items-center justify-between pl-6 transition-none ${
|
||||
!sidebarExpanded ? 'my-8 overflow-hidden sm:my-2' : 'my-4'
|
||||
}`}
|
||||
>
|
||||
<h1 className="ml-1 flex shrink-0 items-center gap-2 whitespace-nowrap text-xl font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
<Icon icon="tabler:hammer" className="text-3xl text-custom-500" />
|
||||
{sidebarExpanded && (
|
||||
<div>
|
||||
LifeForge<span className="text-3xl text-custom-500"> .</span>
|
||||
</div>
|
||||
)}
|
||||
</h1>
|
||||
{sidebarExpanded && (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-6 text-neutral-500 transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Icon icon="tabler:menu" className="text-2xl" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarHeader
|
||||
@@ -1,132 +0,0 @@
|
||||
import { Icon } from '@iconify/react'
|
||||
import React, { useContext, useEffect, useState } from 'react'
|
||||
import { GlobalStateContext } from '../../../providers/GlobalStateProvider'
|
||||
import { useLocation } from 'react-router'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface SidebarItemProps {
|
||||
icon: string
|
||||
name: string
|
||||
subsection?: string[][]
|
||||
isMainSidebarItem?: boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
function titleToPath(title: string): string {
|
||||
return title.toLowerCase().replace(' ', '-')
|
||||
}
|
||||
|
||||
function SidebarItem({
|
||||
icon,
|
||||
name,
|
||||
subsection,
|
||||
isMainSidebarItem,
|
||||
active
|
||||
}: SidebarItemProps): React.JSX.Element {
|
||||
const { sidebarExpanded, toggleSidebar } =
|
||||
isMainSidebarItem === true
|
||||
? useContext(GlobalStateContext)
|
||||
: { sidebarExpanded: true }
|
||||
const [subsectionExpanded, setSubsectionExpanded] = useState(false)
|
||||
|
||||
function toggleSubsection(): void {
|
||||
setSubsectionExpanded(!subsectionExpanded)
|
||||
}
|
||||
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
setSubsectionExpanded(
|
||||
location.pathname.slice(1).startsWith(titleToPath(name))
|
||||
)
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
className={`relative flex items-center gap-6 px-4 font-medium transition-all ${
|
||||
location.pathname.slice(1).startsWith(titleToPath(name))
|
||||
? "text-neutral-800 after:absolute after:right-0 after:top-1/2 after:h-8 after:w-1 after:-translate-y-1/2 after:rounded-full after:bg-custom-500 after:content-[''] dark:text-neutral-100"
|
||||
: 'text-neutral-500 dark:text-neutral-400'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative flex w-full items-center justify-between gap-6 whitespace-nowrap rounded-lg p-4 hover:bg-neutral-200/30 dark:hover:bg-neutral-800 ${
|
||||
location.pathname.slice(1).startsWith(titleToPath(name)) ||
|
||||
active === true
|
||||
? 'bg-neutral-200/50 dark:bg-neutral-800'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-6">
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={`h-6 w-6 shrink-0 ${
|
||||
location.pathname.slice(1).startsWith(titleToPath(name)) ||
|
||||
active === true
|
||||
? 'text-custom-500'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
<span className="w-full truncate">{sidebarExpanded && name}</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 1024) {
|
||||
toggleSidebar()
|
||||
}
|
||||
}}
|
||||
to={`./${titleToPath(name)}`}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-lg"
|
||||
/>
|
||||
{sidebarExpanded && (
|
||||
<div className="relative flex items-center justify-between">
|
||||
{subsection !== undefined && (
|
||||
<button
|
||||
onClick={toggleSubsection}
|
||||
className="rounded-full p-1 hover:bg-neutral-200 dark:hover:bg-neutral-700/50"
|
||||
>
|
||||
<Icon
|
||||
icon="tabler:chevron-right"
|
||||
className={`stroke-[2px] text-neutral-400 transition-all ${
|
||||
subsectionExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{sidebarExpanded && subsection !== undefined && (
|
||||
<li
|
||||
className={`flex h-auto shrink-0 flex-col gap-2 overflow-hidden ${
|
||||
subsectionExpanded ? 'max-h-[1000px] py-2' : 'max-h-0 py-0'
|
||||
}`}
|
||||
>
|
||||
{subsection.map(
|
||||
([subsectionName, subsectionIcon, subsectionLink]) => (
|
||||
<Link
|
||||
to={`./${titleToPath(name)}/${subsectionLink}`}
|
||||
key={subsectionName}
|
||||
className={`mx-4 flex items-center gap-4 rounded-lg py-4 pl-[3.8rem] font-medium transition-all hover:bg-neutral-200/30 dark:hover:bg-neutral-800 ${
|
||||
location.pathname.split('/').slice(1)[0] ===
|
||||
titleToPath(name) &&
|
||||
location.pathname.split('/').slice(1)[1] === subsectionLink
|
||||
? 'bg-neutral-200/50 dark:bg-neutral-800'
|
||||
: 'text-neutral-400'
|
||||
}`}
|
||||
>
|
||||
<Icon icon={subsectionIcon} className="h-6 w-6" />
|
||||
{subsectionName}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarItem
|
||||
@@ -1,94 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useContext, useEffect, useState } from 'react'
|
||||
import { GlobalStateContext } from '../../../providers/GlobalStateProvider'
|
||||
import SidebarItem from './SidebarItem'
|
||||
import SidebarTitle from './SidebarTitle'
|
||||
import SidebarDivider from './SidebarDivider'
|
||||
import SIDEBAR_ITEMS from '../../../constants/sidebar'
|
||||
import { toast } from 'react-toastify'
|
||||
import { type INotesWorkspace } from '../../../modules/Notes'
|
||||
|
||||
function SidebarItems(): React.JSX.Element {
|
||||
const { sidebarExpanded } = useContext(GlobalStateContext)
|
||||
|
||||
const [sidebarItems, setSidebarItems] = useState(SIDEBAR_ITEMS)
|
||||
|
||||
const [notesCategories, setNotesCategories] = useState<
|
||||
INotesWorkspace[] | 'error' | 'loading'
|
||||
>('loading')
|
||||
|
||||
function updateNotesCategory(): void {
|
||||
setNotesCategories('loading')
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/notes/workspace/list`)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
setNotesCategories(data.data)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setNotesCategories('error')
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateNotesCategory()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (notesCategories !== 'loading' && notesCategories !== 'error') {
|
||||
setSidebarItems(
|
||||
sidebarItems.map(item => {
|
||||
if (item.name === 'Notes') {
|
||||
return {
|
||||
...item,
|
||||
subsection: notesCategories.map(({ name, icon, id }) => [
|
||||
name,
|
||||
icon,
|
||||
id
|
||||
])
|
||||
}
|
||||
} else {
|
||||
return item
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}, [notesCategories])
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1 overflow-y-hidden overscroll-none pb-6 hover:overflow-y-scroll">
|
||||
{sidebarItems.map(({ type, name, icon, subsection }, index) => {
|
||||
switch (type) {
|
||||
case 'item':
|
||||
return (
|
||||
<SidebarItem
|
||||
key={type + index}
|
||||
name={name!}
|
||||
icon={icon ?? ''}
|
||||
subsection={subsection}
|
||||
isMainSidebarItem
|
||||
/>
|
||||
)
|
||||
case 'title':
|
||||
return sidebarExpanded ? (
|
||||
<SidebarTitle key={type + index} name={name!} />
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
case 'divider':
|
||||
return <SidebarDivider key={type + index} />
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarItems
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
|
||||
interface SidebarItemProps {
|
||||
name: string
|
||||
actionButtonIcon?: string
|
||||
actionButtonOnClick?: () => void
|
||||
}
|
||||
|
||||
function SidebarTitle({
|
||||
name,
|
||||
actionButtonIcon,
|
||||
actionButtonOnClick
|
||||
}: SidebarItemProps): React.JSX.Element {
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-4 py-4 pl-8 pr-5 pt-2 transition-all">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-widest text-neutral-600">
|
||||
{name}
|
||||
</h3>
|
||||
{actionButtonIcon !== undefined && (
|
||||
<button
|
||||
onClick={actionButtonOnClick}
|
||||
className="flex items-center rounded-md p-2 text-neutral-600 hover:bg-neutral-200/50 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Icon icon={actionButtonIcon} className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarTitle
|
||||
@@ -1,22 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useContext } from 'react'
|
||||
import { GlobalStateContext } from '../../providers/GlobalStateProvider'
|
||||
import SidebarItems from './components/SidebarItems'
|
||||
import SidebarHeader from './components/SidebarHeader'
|
||||
|
||||
export default function Sidebar(): React.JSX.Element {
|
||||
const { sidebarExpanded } = useContext(GlobalStateContext)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`${
|
||||
sidebarExpanded
|
||||
? 'w-full sm:w-1/2 lg:w-3/12 xl:w-1/5'
|
||||
: 'w-0 sm:w-[5.4rem]'
|
||||
} absolute left-0 top-0 z-[9999] flex h-full shrink-0 flex-col rounded-r-2xl bg-neutral-50 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] duration-300 dark:bg-[#1e1e1e] lg:relative`}
|
||||
>
|
||||
<SidebarHeader />
|
||||
<SidebarItems />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Modal from './Modal'
|
||||
import { Colorful, EditableInput } from '@uiw/react-color'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
|
||||
function checkContrast(hexcolor: string): string {
|
||||
const r = parseInt(hexcolor.substr(1, 2), 16)
|
||||
const g = parseInt(hexcolor.substr(3, 2), 16)
|
||||
const b = parseInt(hexcolor.substr(5, 2), 16)
|
||||
const yiq = (r * 299 + g * 587 + b * 114) / 1000
|
||||
return yiq >= 128 ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
function ColorPickerModal({
|
||||
isOpen,
|
||||
setOpen,
|
||||
color,
|
||||
setColor
|
||||
}: {
|
||||
isOpen: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
color: string
|
||||
setColor: React.Dispatch<React.SetStateAction<string>>
|
||||
}): React.ReactElement {
|
||||
const [innerColor, setInnerColor] = useState(color.toLowerCase())
|
||||
|
||||
function confirmColor(): void {
|
||||
setColor(innerColor)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setInnerColor(color.toLowerCase())
|
||||
}, [color])
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen}>
|
||||
<div className="mb-8 flex items-center justify-between ">
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold">
|
||||
<Icon icon="tabler:palette" className="h-7 w-7" />
|
||||
Color picker
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
}}
|
||||
className="rounded-md p-2 text-neutral-100 transition-all hover:bg-neutral-800"
|
||||
>
|
||||
<Icon icon="tabler:x" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<Colorful
|
||||
color={innerColor}
|
||||
onChange={color => {
|
||||
setInnerColor(color.hex)
|
||||
}}
|
||||
disableAlpha
|
||||
className="!w-full"
|
||||
/>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `.w-color-editable-input input {
|
||||
background-color: ${innerColor} !important;
|
||||
color: ${checkContrast(innerColor)} !important;
|
||||
}`
|
||||
}}
|
||||
/>
|
||||
<EditableInput
|
||||
label="Hex"
|
||||
value={innerColor}
|
||||
onChange={e => {
|
||||
setInnerColor(`#${e.target.value}`)
|
||||
}}
|
||||
className="mt-4 border-0 p-4 text-2xl font-semibold"
|
||||
/>
|
||||
<button
|
||||
onClick={confirmColor}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-800 transition-all hover:bg-custom-600"
|
||||
>
|
||||
<Icon icon="tabler:check" className="h-6 w-6" />
|
||||
SELECT
|
||||
</button>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColorPickerModal
|
||||
@@ -1,38 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
|
||||
function EmptyStateScreen({
|
||||
setModifyModalOpenType,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
ctaContent
|
||||
}: {
|
||||
setModifyModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
ctaContent: string
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-6 ">
|
||||
<Icon icon={icon} className="h-32 w-32" />
|
||||
<h2 className="text-3xl font-semibold">{title}</h2>
|
||||
<p className="-mt-2 text-xl text-neutral-500">{description}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setModifyModalOpenType('create')
|
||||
}}
|
||||
className="mt-6 flex items-center gap-2 rounded-full bg-custom-500 p-4 px-6 pr-7 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-custom-600 dark:text-neutral-800"
|
||||
>
|
||||
<Icon icon="tabler:plus" className="text-xl" />
|
||||
{ctaContent}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyStateScreen
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
|
||||
function Error({ message }: { message: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-6">
|
||||
<Icon icon="tabler:alert-triangle" className="h-12 w-12 text-red-500" />
|
||||
<p className="text-lg font-medium text-red-500">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Error
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
|
||||
function GoBackButton({
|
||||
onClick
|
||||
}: {
|
||||
onClick: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="-ml-1 mb-2 flex w-min items-center gap-1 rounded-lg pl-0 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-100 sm:gap-2"
|
||||
>
|
||||
<Icon icon="tabler:chevron-left" className="text-lg sm:text-xl" />
|
||||
<span className="whitespace-nowrap text-base font-medium sm:text-lg">
|
||||
Go back
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default GoBackButton
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Menu } from '@headlessui/react'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
text,
|
||||
isRed = false,
|
||||
onClick
|
||||
}: {
|
||||
icon: string
|
||||
text: string
|
||||
isRed?: boolean
|
||||
onClick: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`${
|
||||
active
|
||||
? `bg-neutral-200/50 ${
|
||||
isRed ? 'text-red-600' : 'text-neutral-800'
|
||||
} dark:bg-neutral-700 dark:text-neutral-100`
|
||||
: isRed
|
||||
? 'text-red-500'
|
||||
: 'text-neutral-500'
|
||||
} flex w-full items-center p-4`}
|
||||
>
|
||||
<Icon icon={icon} className="h-5 w-5" />
|
||||
<span className="ml-2">{text}</span>
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export default MenuItem
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
|
||||
function HamburgerMenu({
|
||||
children,
|
||||
position
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
position: string
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Menu as="div" className={position}>
|
||||
<Menu.Button className="rounded-md p-2 text-neutral-500 hover:bg-neutral-200/50 hover:text-neutral-500 dark:hover:bg-neutral-700/30">
|
||||
<Icon icon="tabler:dots-vertical" className="h-5 w-5" />
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
className="absolute right-0 top-3 z-50"
|
||||
>
|
||||
<Menu.Items className="mt-8 w-48 overflow-hidden overscroll-contain rounded-md bg-neutral-100 shadow-lg outline-none focus:outline-none dark:bg-neutral-800">
|
||||
{children}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export default HamburgerMenu
|
||||
@@ -1,74 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React, { useState } from 'react'
|
||||
import IconSelector from '.'
|
||||
|
||||
function IconInput({
|
||||
name,
|
||||
icon,
|
||||
setIcon
|
||||
}: {
|
||||
name: string
|
||||
icon: string
|
||||
setIcon: React.Dispatch<React.SetStateAction<string>>
|
||||
}): React.ReactElement {
|
||||
const [iconSelectorOpen, setIconSelectorOpen] = useState(false)
|
||||
|
||||
function updateIcon(e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setIcon(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="group relative mt-6 flex items-center gap-1 rounded-t-lg border-b-2 border-neutral-500 bg-neutral-200/50 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] focus-within:border-custom-500 dark:bg-neutral-800/50">
|
||||
<Icon
|
||||
icon="tabler:icons"
|
||||
className={`ml-6 h-6 w-6 shrink-0 ${
|
||||
icon ? 'text-neutral-100' : 'text-neutral-500'
|
||||
} group-focus-within:text-custom-500`}
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span
|
||||
className={`pointer-events-none absolute left-[4.2rem] font-medium tracking-wide text-neutral-500 group-focus-within:text-custom-500 ${
|
||||
icon.length === 0
|
||||
? 'top-1/2 -translate-y-1/2 group-focus-within:top-6 group-focus-within:text-[14px]'
|
||||
: 'top-6 -translate-y-1/2 text-[14px]'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<div className="mr-12 mt-6 flex w-full items-center gap-2 pl-4">
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 rounded-full ${
|
||||
!icon && 'hidden group-focus-within:block'
|
||||
}`}
|
||||
icon={icon || 'tabler:question-mark'}
|
||||
/>
|
||||
<input
|
||||
value={icon}
|
||||
onChange={updateIcon}
|
||||
placeholder="tabler:cube"
|
||||
className="h-8 w-full rounded-lg bg-transparent p-6 pl-0 tracking-wide placeholder:text-transparent focus:outline-none focus:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIconSelectorOpen(true)
|
||||
}}
|
||||
className="mr-4 shrink-0 rounded-lg p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-900 focus:outline-none dark:hover:bg-neutral-500/30 dark:hover:text-neutral-200"
|
||||
>
|
||||
<Icon icon="tabler:chevron-down" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<IconSelector
|
||||
isOpen={iconSelectorOpen}
|
||||
setOpen={setIconSelectorOpen}
|
||||
setSelectedIcon={setIcon}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconInput
|
||||
@@ -1,137 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
/* eslint-disable react/jsx-one-expression-per-line */
|
||||
/* eslint-disable operator-linebreak */
|
||||
/* eslint-disable implicit-arrow-linebreak */
|
||||
/* eslint-disable react/prop-types */
|
||||
import { Icon } from '@iconify/react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Input from './Input'
|
||||
|
||||
export interface IIconSetData {
|
||||
title: string
|
||||
total: number
|
||||
prefix: string
|
||||
uncategorized: string[]
|
||||
categories: Record<string, string[]>
|
||||
}
|
||||
|
||||
async function getIconSet(prefix: string): Promise<any> {
|
||||
try {
|
||||
const res: IIconSetData = await fetch(
|
||||
`https://api.iconify.design/collection?prefix=${prefix}`
|
||||
).then(async res => await res.json())
|
||||
|
||||
console.log(res)
|
||||
if (!res.uncategorized) {
|
||||
res.uncategorized = Object.values(res.categories).flat()
|
||||
}
|
||||
|
||||
return res
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function IconSet({
|
||||
setOpen,
|
||||
iconSet,
|
||||
setSelectedIcon
|
||||
}: {
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
iconSet: string
|
||||
setSelectedIcon: React.Dispatch<React.SetStateAction<string>>
|
||||
}): React.ReactElement {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [currentTag, setCurrentTag] = useState<string | null>(null)
|
||||
const [iconData, setIconData] = useState<IIconSetData | null>(null)
|
||||
const [filteredIconList, setFilteredIconList] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
getIconSet(iconSet).then(data => {
|
||||
setIconData(data)
|
||||
setFilteredIconList(data.uncategorized)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (iconData) {
|
||||
if (currentTag) {
|
||||
setFilteredIconList(
|
||||
iconData.categories[currentTag].filter(icon =>
|
||||
icon.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setFilteredIconList(
|
||||
iconData.uncategorized.filter(icon =>
|
||||
icon.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [searchTerm, currentTag, iconData])
|
||||
|
||||
return iconData ? (
|
||||
<div className="flex min-h-0 w-full flex-col overflow-scroll p-8">
|
||||
<h1 className="mb-6 flex flex-col items-center gap-1 text-center text-3xl font-semibold tracking-wide sm:inline">
|
||||
{iconData.title}
|
||||
</h1>
|
||||
<Input
|
||||
value={searchTerm}
|
||||
setValue={setSearchTerm}
|
||||
placeholder={`Search ${iconData.total.toLocaleString()} icons`}
|
||||
icon="uil:search"
|
||||
/>
|
||||
{Object.keys(iconData.categories || {}).length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-2">
|
||||
{Object.keys(iconData.categories).map(
|
||||
tag =>
|
||||
tag && (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCurrentTag(currentTag === tag ? null : tag)
|
||||
}}
|
||||
className={`${
|
||||
currentTag === tag
|
||||
? '!bg-neutral-200 !text-neutral-800 shadow-md'
|
||||
: ''
|
||||
} flex h-8 grow items-center justify-center whitespace-nowrap rounded-full bg-neutral-800 px-6 text-sm text-neutral-100 shadow-md transition-all md:grow-0`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-8 grid min-h-0 w-full grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3 pb-8">
|
||||
{filteredIconList.map(icon => (
|
||||
<button
|
||||
key={icon}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedIcon(`${iconSet}:${icon}`)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex cursor-pointer flex-col items-center rounded-lg p-4 transition-all hover:bg-neutral-800"
|
||||
>
|
||||
<Icon icon={`${iconSet}:${icon}`} width="32" height="32" />
|
||||
<p className="-mb-0.5 mt-4 break-all text-center text-xs font-medium tracking-wide">
|
||||
{icon.replace(/-/g, ' ')}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full justify-center pb-8">
|
||||
<Icon icon="svg-spinners:270-ring" className="h-8 w-8" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconSet
|
||||
@@ -1,179 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
/* eslint-disable react/jsx-one-expression-per-line */
|
||||
/* eslint-disable operator-linebreak */
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/jsx-indent */
|
||||
/* eslint-disable implicit-arrow-linebreak */
|
||||
/* eslint-disable no-param-reassign */
|
||||
import React, { useState } from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
import { collections } from '@iconify/collections'
|
||||
import Input from './Input'
|
||||
|
||||
const ICON_SETS = {
|
||||
General: [],
|
||||
'Animated Icons': [],
|
||||
'Brands / Social': [],
|
||||
Emoji: [],
|
||||
'Maps / Flags': [],
|
||||
Thematic: [],
|
||||
'Archive / Unmaintained': [],
|
||||
Other: []
|
||||
}
|
||||
|
||||
Object.entries(collections).forEach(([key, value]) => {
|
||||
value.prefix = key
|
||||
ICON_SETS[value.category || 'Other'] = [
|
||||
...ICON_SETS[value.category || 'Other'],
|
||||
value
|
||||
]
|
||||
})
|
||||
|
||||
const ICON_COUNT = Object.values(ICON_SETS)
|
||||
.flat()
|
||||
.map(e => e.total)
|
||||
.reduce((a, b) => a + b)
|
||||
|
||||
export default function IconSetList({
|
||||
setCurrentIconSet
|
||||
}: {
|
||||
setCurrentIconSet: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
iconSet?: string
|
||||
search?: string
|
||||
}>
|
||||
>
|
||||
}): React.ReactElement {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState(null)
|
||||
const [iconFilterTerm, setIconFilterTerm] = useState('')
|
||||
|
||||
return (
|
||||
<div className="overflow-scroll p-8 pb-2 pt-0">
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
setValue={setSearchQuery}
|
||||
placeholder={`Search ${ICON_COUNT.toLocaleString()} icons`}
|
||||
icon="uil:search"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (searchQuery) setCurrentIconSet({ search: searchQuery })
|
||||
}}
|
||||
className="flex items-center justify-center gap-1 rounded-md bg-neutral-200 px-6 py-4 font-medium text-neutral-900 shadow-md transition-all hover:bg-[#b3bdc9]"
|
||||
>
|
||||
Search
|
||||
<Icon icon="uil:arrow-right" className="h-5 w-5 text-neutral-900" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-8 lg:flex-row">
|
||||
<div className="mt-4 flex w-full flex-wrap gap-2">
|
||||
{Object.keys(ICON_SETS).map((category, index) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCategory(selectedCategory === index ? null : index)
|
||||
}}
|
||||
className={`${
|
||||
selectedCategory === index
|
||||
? 'bg-neutral-200 text-neutral-800 shadow-md'
|
||||
: ''
|
||||
} flex h-8 grow items-center justify-center whitespace-nowrap rounded-full bg-neutral-800 px-6 text-sm text-neutral-100 shadow-md transition-all md:grow-0`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full lg:w-3/5 xl:w-1/3">
|
||||
<Input
|
||||
value={iconFilterTerm}
|
||||
setValue={setIconFilterTerm}
|
||||
placeholder="Filter icon sets"
|
||||
icon="uil:filter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 flex min-h-0 w-full flex-col items-center overflow-scroll">
|
||||
<div className="flex w-full flex-col">
|
||||
{Object.entries(ICON_SETS).map(
|
||||
([category, iconSets], index) =>
|
||||
Boolean(
|
||||
(selectedCategory === null || selectedCategory === index) &&
|
||||
iconSets.filter(
|
||||
iconSet =>
|
||||
!iconFilterTerm.trim() ||
|
||||
iconSet.name
|
||||
.toLowerCase()
|
||||
.includes(iconFilterTerm.trim().toLowerCase())
|
||||
).length
|
||||
) && (
|
||||
<div key={category} className="mb-6 w-full overflow-hidden">
|
||||
<div className="relative mb-8 rounded-lg text-center text-2xl font-medium after:absolute after:-bottom-2 after:left-1/2 after:w-8 after:-translate-x-1/2 after:border-b-2 after:border-b-neutral-200">
|
||||
{category}
|
||||
</div>
|
||||
<div className="icon-list grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] flex-wrap gap-4">
|
||||
{iconSets.map(
|
||||
iconSet =>
|
||||
(!iconFilterTerm.trim() ||
|
||||
iconSet.name
|
||||
.toLowerCase()
|
||||
.includes(iconFilterTerm.trim().toLowerCase())) && (
|
||||
<button
|
||||
key={iconSet.prefix}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCurrentIconSet({ iconSet: iconSet.prefix })
|
||||
}}
|
||||
className="sssm:flex-row flex w-full grow flex-col overflow-hidden rounded-md bg-neutral-800 shadow-lg"
|
||||
>
|
||||
<div className="sssm:w-36 text-neutral-00 flex w-full shrink-0 flex-col font-medium">
|
||||
<div className="sssm:gap-3 flex h-full w-full items-center justify-center gap-5 px-4 py-6 ">
|
||||
{iconSet.samples.map(sampleIcon => (
|
||||
<Icon
|
||||
key={sampleIcon}
|
||||
icon={`${iconSet.prefix}:${sampleIcon}`}
|
||||
className="h-8 w-8 shrink-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-between px-4 pb-3 text-left">
|
||||
<h3 className="truncate text-xl font-semibold">
|
||||
{iconSet.name}
|
||||
</h3>
|
||||
<p className="mt-1 truncate text-sm">
|
||||
By
|
||||
<span className="underline">
|
||||
{iconSet.author.name}
|
||||
</span>
|
||||
</p>
|
||||
<div className="sssm:py-0 mt-4 flex w-full items-center justify-between border-t border-neutral-500 pt-4 text-sm">
|
||||
<p>{iconSet.total} icons</p>
|
||||
{iconSet.height && (
|
||||
<div className="flex items-center">
|
||||
<Icon
|
||||
icon="icon-park-outline:auto-height-one"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
<p className="ml-1">{iconSet.height}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/* eslint-disable object-curly-newline */
|
||||
/* eslint-disable react/prop-types */
|
||||
import { Icon } from '@iconify/react'
|
||||
import React from 'react'
|
||||
|
||||
function Input({
|
||||
value,
|
||||
setValue,
|
||||
placeholder,
|
||||
icon
|
||||
}: {
|
||||
value: string
|
||||
setValue: React.Dispatch<React.SetStateAction<string>>
|
||||
placeholder: string
|
||||
icon: string
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3 rounded-md bg-neutral-800 px-5 shadow-md">
|
||||
<Icon icon={icon} className="h-5 w-5 text-neutral-200" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-transparent py-4 text-neutral-200 outline-none placeholder:text-neutral-100"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={e => {
|
||||
setValue(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Input
|
||||
@@ -1,151 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
/* eslint-disable @typescript-eslint/require-array-sort-compare */
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
/* eslint-disable react/jsx-one-expression-per-line */
|
||||
/* eslint-disable operator-linebreak */
|
||||
/* eslint-disable implicit-arrow-linebreak */
|
||||
/* eslint-disable react/prop-types */
|
||||
import { Icon } from '@iconify/react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Input from './Input'
|
||||
|
||||
async function getIconSet(searchTerm: string): Promise<any> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://cors-anywhere.thecodeblog.net/api.iconify.design/search?query=${searchTerm}&limit=9999`
|
||||
)
|
||||
const data = await res.json()
|
||||
let iconList = []
|
||||
if (data.icons.length) {
|
||||
iconList = data.icons
|
||||
} else {
|
||||
iconList = []
|
||||
}
|
||||
const iconSets = data.collections
|
||||
|
||||
return {
|
||||
iconList,
|
||||
iconSets
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function Search({
|
||||
setOpen,
|
||||
searchTerm,
|
||||
setSelectedIcon,
|
||||
setCurrentIconSet: setCurrentIconSetProp
|
||||
}: {
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
searchTerm: string
|
||||
setSelectedIcon: React.Dispatch<React.SetStateAction<string>>
|
||||
setCurrentIconSet: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
iconSet?: string
|
||||
search?: string
|
||||
}>
|
||||
>
|
||||
}): React.ReactElement {
|
||||
const [currentIconSet, setCurrentIconSet] = useState(null)
|
||||
const [iconData, setIconData] = useState(null)
|
||||
const [filteredIconList, setFilteredIconList] = useState([])
|
||||
const [searchQuery, setSearchQuery] = useState(searchTerm || '')
|
||||
|
||||
useEffect(() => {
|
||||
setIconData(null)
|
||||
getIconSet(searchTerm)
|
||||
.then(data => {
|
||||
setIconData(data)
|
||||
setFilteredIconList(data.iconList)
|
||||
setCurrentIconSet(null)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
})
|
||||
}, [searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
if (iconData) {
|
||||
setFilteredIconList(
|
||||
currentIconSet
|
||||
? iconData.iconList.filter(
|
||||
e => e.split(':').shift() === currentIconSet
|
||||
)
|
||||
: iconData.iconList
|
||||
)
|
||||
}
|
||||
}, [currentIconSet, iconData])
|
||||
|
||||
return iconData ? (
|
||||
<div className="flex min-h-0 w-full flex-col overflow-scroll p-8">
|
||||
<div className="flex w-full gap-2">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
setValue={setSearchQuery}
|
||||
placeholder="Search icons"
|
||||
icon="uil:search"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (searchQuery) setCurrentIconSetProp({ search: searchQuery })
|
||||
}}
|
||||
className="flex items-center justify-center gap-1 rounded-md bg-neutral-200 px-6 py-4 font-medium text-neutral-800 shadow-md transition-all hover:bg-[#b3bdc9]"
|
||||
>
|
||||
Search
|
||||
<Icon icon="uil:arrow-right" className="h-5 w-5 text-neutral-800" />
|
||||
</button>
|
||||
</div>
|
||||
{Object.keys(iconData.iconSets).length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-2">
|
||||
{Object.entries(iconData.iconSets)
|
||||
.sort()
|
||||
.map(([name, iconSet]) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCurrentIconSet(currentIconSet === name ? null : name)
|
||||
}}
|
||||
className={`${
|
||||
currentIconSet === name
|
||||
? 'bg-neutral-200 text-neutral-800 shadow-md'
|
||||
: 'bg-neutral-800'
|
||||
} flex h-8 grow items-center justify-center whitespace-nowrap rounded-full px-6 text-sm text-neutral-100 shadow-md transition-all md:grow-0`}
|
||||
>
|
||||
{iconSet.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className=" mt-8 grid min-h-0 grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-3 pb-8">
|
||||
{filteredIconList.map(icon => (
|
||||
<button
|
||||
key={icon}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedIcon(icon.name || icon)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex cursor-pointer flex-col items-center rounded-lg p-4 transition-all hover:bg-neutral-800"
|
||||
>
|
||||
<Icon icon={icon.name || icon} width="32" height="32" />
|
||||
<p className="-mb-0.5 mt-4 break-all text-center text-xs font-medium tracking-wide">
|
||||
{(icon.name || icon).replace(/-/g, ' ')}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full justify-center pb-8">
|
||||
<Icon icon="svg-spinners:270-ring" className="h-8 w-8" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Search
|
||||
@@ -1,103 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable no-nested-ternary */
|
||||
/* eslint-disable react/jsx-one-expression-per-line */
|
||||
/* eslint-disable react/jsx-indent */
|
||||
/* eslint-disable operator-linebreak */
|
||||
/* eslint-disable implicit-arrow-linebreak */
|
||||
/* eslint-disable no-param-reassign */
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, { useState } from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
|
||||
import IconSetList from './components/IconSetList'
|
||||
import IconSet from './components/IconSet.tsx'
|
||||
import Search from './components/Search'
|
||||
|
||||
function IconSelector({
|
||||
isOpen,
|
||||
setOpen,
|
||||
setSelectedIcon
|
||||
}: {
|
||||
isOpen: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setSelectedIcon: React.Dispatch<React.SetStateAction<string>>
|
||||
}): React.ReactElement {
|
||||
const [currentIconSet, setCurrentIconSet] = useState(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute left-0 top-0 h-screen w-full bg-neutral-900 transition-colors duration-500 ${
|
||||
isOpen ? 'z-[9999] bg-opacity-50' : 'z-[-1] bg-opacity-0 delay-500'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute left-0 top-0 flex h-screen w-full items-center justify-center transition-all duration-500 ${
|
||||
isOpen ? 'translate-y-0' : 'translate-y-[110%]'
|
||||
}`}
|
||||
>
|
||||
<div className="510:mx-16 relative mx-4 flex max-h-[calc(100vh-8rem)] w-full flex-col items-center justify-center rounded-lg bg-neutral-900 shadow-2xl lg:w-3/4">
|
||||
<div className="mb-6 flex w-full items-center justify-between p-8 pb-0">
|
||||
{currentIconSet ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setCurrentIconSet(null)
|
||||
}}
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-lg"
|
||||
>
|
||||
<Icon icon="uil:arrow-left" className="h-7 w-7" />
|
||||
Go Back
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-end gap-2">
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold">
|
||||
<Icon icon="tabler:icons" className="h-7 w-7" />
|
||||
Icon Selector
|
||||
</h1>
|
||||
<p className="shrink-0 text-right text-sm sm:text-base">
|
||||
powered by
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://iconify.thecodeblog.net"
|
||||
className="underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Iconify
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
}}
|
||||
className="rounded-md p-2 text-neutral-100 transition-all hover:bg-neutral-800"
|
||||
>
|
||||
<Icon icon="tabler:x" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
{currentIconSet ? (
|
||||
currentIconSet.search ? (
|
||||
<Search
|
||||
searchTerm={currentIconSet.search}
|
||||
setCurrentIconSet={setCurrentIconSet}
|
||||
setSelectedIcon={setSelectedIcon}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
) : (
|
||||
<IconSet
|
||||
iconSet={currentIconSet.iconSet}
|
||||
setSelectedIcon={setSelectedIcon}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<IconSetList setCurrentIconSet={setCurrentIconSet} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconSelector
|
||||
@@ -1,58 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
|
||||
function Input({
|
||||
name,
|
||||
placeholder,
|
||||
icon,
|
||||
value,
|
||||
updateValue,
|
||||
isPassword = false,
|
||||
darker = false,
|
||||
additionalClassName = ''
|
||||
}: {
|
||||
name: string
|
||||
placeholder: string
|
||||
icon: string
|
||||
value: string
|
||||
updateValue: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
isPassword?: boolean
|
||||
darker?: boolean
|
||||
additionalClassName?: string
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-center gap-1 rounded-t-lg border-b-2 border-neutral-500 bg-neutral-200/50 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] focus-within:border-custom-500 ${
|
||||
darker ? 'dark:bg-neutral-800/50' : 'dark:bg-neutral-800'
|
||||
} ${additionalClassName}`}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={`ml-6 h-6 w-6 shrink-0 ${
|
||||
value ? 'text-neutral-100' : 'text-neutral-500'
|
||||
} group-focus-within:text-custom-500`}
|
||||
/>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span
|
||||
className={`pointer-events-none absolute left-[4.2rem] font-medium tracking-wide text-neutral-500 group-focus-within:text-custom-500 ${
|
||||
value.length === 0
|
||||
? 'top-1/2 -translate-y-1/2 group-focus-within:top-6 group-focus-within:text-[14px]'
|
||||
: 'top-6 -translate-y-1/2 text-[14px]'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<input
|
||||
type={isPassword ? 'password' : 'text'}
|
||||
value={value}
|
||||
onChange={updateValue}
|
||||
placeholder={placeholder}
|
||||
className="mt-6 h-8 w-full rounded-lg bg-transparent p-6 pl-4 tracking-wider placeholder:text-transparent focus:outline-none focus:placeholder:text-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Input
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function Loading(): React.ReactElement {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-6">
|
||||
<span className="loader"></span>
|
||||
<p className="text-lg font-medium text-neutral-500">Loading data</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
function Modal({
|
||||
isOpen,
|
||||
children
|
||||
}: {
|
||||
isOpen: boolean
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`fixed left-0 top-0 h-screen w-full bg-neutral-950/60 backdrop-blur-md transition-opacity ease-linear ${
|
||||
isOpen
|
||||
? 'z-[9999] opacity-100'
|
||||
: 'z-[-1] opacity-0 [transition:z-index_0.1s_linear_0.5s,opacity_0.1s_linear_0.1s]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute ${
|
||||
isOpen ? 'top-1/2' : 'top-[200vh]'
|
||||
} left-1/2 flex max-h-[calc(100vh-8rem)] max-w-[calc(100vw-4rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl bg-neutral-100 p-6 transition-all duration-300 dark:bg-neutral-900`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Modal
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Icon } from '@iconify/react'
|
||||
import React from 'react'
|
||||
|
||||
interface ModuleHeaderPropsWithHamburgerMenu {
|
||||
title: string | React.ReactNode
|
||||
desc?: string | React.ReactNode
|
||||
hasHamburgerMenu?: false
|
||||
hamburgerMenuItems?: never
|
||||
}
|
||||
|
||||
interface ModuleHeaderPropsWithHamburgerMenuItems {
|
||||
title: string | React.ReactNode
|
||||
desc?: string | React.ReactNode
|
||||
hasHamburgerMenu?: true
|
||||
hamburgerMenuItems?: React.ReactNode
|
||||
}
|
||||
|
||||
type ModuleHeaderProps =
|
||||
| ModuleHeaderPropsWithHamburgerMenu
|
||||
| ModuleHeaderPropsWithHamburgerMenuItems
|
||||
|
||||
function ModuleHeader({
|
||||
title,
|
||||
desc,
|
||||
hasHamburgerMenu = false,
|
||||
hamburgerMenuItems
|
||||
}: ModuleHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="flex items-center gap-3 text-3xl font-semibold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
{desc !== undefined && <div className="text-neutral-500">{desc}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{hasHamburgerMenu && (
|
||||
<Menu as="div" className="relative overscroll-contain">
|
||||
<Menu.Button className="rounded-lg p-4 text-neutral-500 transition-all hover:bg-neutral-200/50 hover:text-neutral-800 dark:hover:bg-neutral-800/50 dark:hover:text-neutral-100">
|
||||
<Icon icon="tabler:dots-vertical" className="h-5 w-5" />
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
className="absolute right-0 top-3"
|
||||
>
|
||||
<Menu.Items className="mt-12 w-48 overflow-hidden overscroll-contain rounded-md bg-neutral-100 shadow-lg outline-none focus:outline-none dark:bg-neutral-800">
|
||||
{hamburgerMenuItems}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModuleHeader
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Icon } from '@iconify/react'
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
function NotFound(): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-6">
|
||||
<h1 className="text-[10rem] text-custom-500">;-;</h1>
|
||||
<h1 className="text-4xl font-semibold">Page not found</h1>
|
||||
<h2 className="-mt-2 text-xl text-neutral-500">
|
||||
The page you are looking for does not exist.
|
||||
</h2>
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center rounded-lg bg-custom-500 px-6 py-4 font-medium uppercase tracking-widest text-white shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] hover:bg-custom-600"
|
||||
>
|
||||
<Icon icon="tabler:arrow-left" className="mr-2 h-5 w-5" />
|
||||
Return home
|
||||
</Link>
|
||||
<Link
|
||||
to="bug-report"
|
||||
className="flex items-center rounded-lg bg-neutral-200 px-6 py-4 font-medium uppercase tracking-widest shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] hover:bg-neutral-300 dark:bg-neutral-800"
|
||||
>
|
||||
<Icon icon="tabler:bug" className="mr-2 h-5 w-5" />
|
||||
Report a bug
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFound
|
||||
@@ -1,6 +0,0 @@
|
||||
export const AUTH_ERROR_MESSAGES = {
|
||||
INVALID_CREDENTIALS: 'Invalid email or password.',
|
||||
UNKNOWN_ERROR: 'Unknown error. Please file a bug report.',
|
||||
DATABASE_NOT_READY: 'Database not ready. Please try again later.',
|
||||
QUOTA_EXCEEDED: 'Quota exceeded. Please try again later.'
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
const FILE_ICONS = {
|
||||
pptx: 'tabler:presentation-analytics',
|
||||
ppt: 'tabler:presentation-analytics',
|
||||
docx: 'tabler:file-text',
|
||||
doc: 'tabler:file-text',
|
||||
xlsx: 'tabler:file-spreadsheet',
|
||||
xls: 'tabler:file-spreadsheet',
|
||||
pdf: 'tabler:file-type-pdf',
|
||||
txt: 'tabler:file-type-txt',
|
||||
zip: 'tabler:file-zip',
|
||||
tex: 'tabler:code',
|
||||
jpg: 'tabler:photo',
|
||||
png: 'tabler:photo',
|
||||
jpeg: 'tabler:photo',
|
||||
gif: 'tabler:photo',
|
||||
svg: 'tabler:svg',
|
||||
mp4: 'tabler:movie',
|
||||
mov: 'tabler:movie',
|
||||
avi: 'tabler:movie',
|
||||
mkv: 'tabler:movie',
|
||||
mp3: 'tabler:music',
|
||||
wav: 'tabler:music',
|
||||
aac: 'tabler:music',
|
||||
flac: 'tabler:music',
|
||||
wma: 'tabler:music',
|
||||
ogg: 'tabler:music',
|
||||
m4a: 'tabler:music',
|
||||
'3gp': 'tabler:music',
|
||||
'3g2': 'tabler:music',
|
||||
'3ga': 'tabler:music',
|
||||
'7z': 'tabler:file-zip',
|
||||
aif: 'tabler:music',
|
||||
aiff: 'tabler:music',
|
||||
amr: 'tabler:music',
|
||||
asf: 'tabler:movie',
|
||||
au: 'tabler:music',
|
||||
bin: 'tabler:binary',
|
||||
dmg: 'tabler:binary',
|
||||
dmgpart: 'tabler:binary',
|
||||
dmgvolume: 'tabler:binary',
|
||||
dll: 'tabler:binary',
|
||||
exe: 'tabler:binary',
|
||||
img: 'tabler:binary',
|
||||
iso: 'tabler:binary',
|
||||
mdf: 'tabler:binary',
|
||||
nrg: 'tabler:binary',
|
||||
o: 'tabler:binary',
|
||||
pkg: 'tabler:binary',
|
||||
qcow: 'tabler:binary',
|
||||
qcow2: 'tabler:binary',
|
||||
qcow2c: 'tabler:binary',
|
||||
r: 'tabler:binary',
|
||||
rpm: 'tabler:binary',
|
||||
sparsebundle: 'tabler:binary',
|
||||
sparseimage: 'tabler:binary',
|
||||
toast: 'tabler:binary',
|
||||
vcd: 'tabler:binary',
|
||||
vmdk: 'tabler:binary',
|
||||
vmem: 'tabler:binary',
|
||||
vmx: 'tabler:binary',
|
||||
vmxf: 'tabler:binary',
|
||||
xar: 'tabler:binary',
|
||||
zipx: 'tabler:binary',
|
||||
a: 'tabler:binary',
|
||||
ar: 'tabler:binary',
|
||||
deb: 'tabler:binary',
|
||||
lbr: 'tabler:binary',
|
||||
lha: 'tabler:binary',
|
||||
mar: 'tabler:binary',
|
||||
rsc: 'tabler:binary',
|
||||
run: 'tabler:binary',
|
||||
shar: 'tabler:binary',
|
||||
uue: 'tabler:binary',
|
||||
xip: 'tabler:binary',
|
||||
z: 'tabler:binary',
|
||||
apk: 'tabler:brand-android',
|
||||
app: 'tabler:brand-apple',
|
||||
bat: 'tabler:file-code',
|
||||
cmd: 'tabler:file-code',
|
||||
com: 'tabler:file-code',
|
||||
cpl: 'tabler:file-code',
|
||||
csh: 'tabler:file-code',
|
||||
gadget: 'tabler:file-code',
|
||||
inf1: 'tabler:file-code',
|
||||
ins: 'tabler:file-code',
|
||||
inx: 'tabler:file-code',
|
||||
isu: 'tabler:file-code',
|
||||
job: 'tabler:file-code',
|
||||
jse: 'tabler:file-code',
|
||||
ksh: 'tabler:file-code',
|
||||
lnk: 'tabler:file-code',
|
||||
ms: 'tabler:file-code',
|
||||
msc: 'tabler:file-code',
|
||||
msh: 'tabler:file-code',
|
||||
paf: 'tabler:file-code',
|
||||
pif: 'tabler:file-code',
|
||||
ps1: 'tabler:file-code',
|
||||
reg: 'tabler:file-code',
|
||||
rgs: 'tabler:file-code',
|
||||
sct: 'tabler:file-code',
|
||||
shb: 'tabler:file-code',
|
||||
shs: 'tabler:file-code',
|
||||
u3p: 'tabler:file-code',
|
||||
vb: 'tabler:file-code',
|
||||
vbe: 'tabler:file-code',
|
||||
vbs: 'tabler:file-code',
|
||||
vbscript: 'tabler:file-code',
|
||||
workflow: 'tabler:file-code',
|
||||
ws: 'tabler:file-code',
|
||||
wsf: 'tabler:file-code',
|
||||
xpl: 'tabler:file-code',
|
||||
action: 'tabler:file-code',
|
||||
cgi: 'tabler:file-code',
|
||||
jar: 'tabler:file-code',
|
||||
pl: 'tabler:file-code',
|
||||
plx: 'tabler:file-code',
|
||||
py: 'tabler:file-code',
|
||||
rb: 'tabler:file-code',
|
||||
scr: 'tabler:file-code',
|
||||
c: 'tabler:file-code',
|
||||
cc: 'tabler:file-code',
|
||||
class: 'tabler:file-code',
|
||||
cpp: 'tabler:file-code',
|
||||
cs: 'tabler:file-code',
|
||||
cxx: 'tabler:file-code',
|
||||
dylib: 'tabler:file-code',
|
||||
lib: 'tabler:file-code',
|
||||
swift: 'tabler:file-code',
|
||||
h: 'tabler:file-code',
|
||||
hh: 'tabler:file-code',
|
||||
hpp: 'tabler:file-code',
|
||||
hxx: 'tabler:file-code',
|
||||
php: 'tabler:file-code',
|
||||
pm: 'tabler:file-code',
|
||||
sh: 'tabler:file-code',
|
||||
vcxproj: 'tabler:file-code',
|
||||
xcodeproj: 'tabler:file-code',
|
||||
xib: 'tabler:file-code',
|
||||
css: 'tabler:file-code',
|
||||
less: 'tabler:file-code',
|
||||
sass: 'tabler:file-code',
|
||||
scss: 'tabler:file-code',
|
||||
styl: 'tabler:file-code',
|
||||
html: 'tabler:file-code',
|
||||
htm: 'tabler:file-code',
|
||||
js: 'tabler:file-code',
|
||||
jsx: 'tabler:file-code',
|
||||
mjs: 'tabler:file-code',
|
||||
ts: 'tabler:file-code',
|
||||
tsx: 'tabler:file-code',
|
||||
vue: 'tabler:file-code',
|
||||
yml: 'tabler:file-code',
|
||||
yaml: 'tabler:file-code',
|
||||
json: 'tabler:file-code',
|
||||
lock: 'tabler:file-code',
|
||||
md: 'tabler:file-code',
|
||||
markdown: 'tabler:file-code',
|
||||
rtf: 'tabler:file-code',
|
||||
csv: 'tabler:file-code',
|
||||
dat: 'tabler:file-code',
|
||||
db: 'tabler:file-code',
|
||||
dbf: 'tabler:file-code',
|
||||
log: 'tabler:file-code',
|
||||
mdb: 'tabler:file-code',
|
||||
sav: 'tabler:file-code',
|
||||
sql: 'tabler:file-code',
|
||||
tar: 'tabler:file-code'
|
||||
}
|
||||
|
||||
export default FILE_ICONS
|
||||
@@ -1,88 +0,0 @@
|
||||
interface ISidebarItem {
|
||||
type: 'item' | 'title' | 'divider'
|
||||
name?: string
|
||||
icon?: string
|
||||
subsection?: string[][]
|
||||
}
|
||||
|
||||
const SIDEBAR_ITEMS: ISidebarItem[] = [
|
||||
{ type: 'item', name: 'Dashboard', icon: 'tabler:layout-dashboard' },
|
||||
{ type: 'divider' },
|
||||
{ type: 'title', name: 'Productivity' },
|
||||
{ type: 'item', name: 'Todo List', icon: 'tabler:list-check' },
|
||||
{ type: 'item', name: 'Daily Schedule', icon: 'tabler:clipboard-list' },
|
||||
{ type: 'item', name: 'Calendar', icon: 'tabler:calendar' },
|
||||
{ type: 'divider' },
|
||||
{ type: 'title', name: 'Development' },
|
||||
{
|
||||
type: 'item',
|
||||
name: 'Projects',
|
||||
icon: 'tabler:clipboard'
|
||||
},
|
||||
{ type: 'item', name: 'Idea Box', icon: 'tabler:bulb' },
|
||||
{ type: 'item', name: 'Snippets', icon: 'tabler:code-dots' },
|
||||
{ type: 'item', name: 'Resources', icon: 'tabler:book' },
|
||||
{ type: 'item', name: 'Code Time', icon: 'tabler:code' },
|
||||
{ type: 'divider' },
|
||||
{ type: 'title', name: 'Study' },
|
||||
{ type: 'item', name: 'Pomodoro Timer', icon: 'tabler:clock-bolt' },
|
||||
{ type: 'item', name: 'Flashcards', icon: 'tabler:cards' },
|
||||
{
|
||||
type: 'item',
|
||||
name: 'Notes',
|
||||
icon: 'tabler:notebook'
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
name: 'Reference Books',
|
||||
icon: 'tabler:books',
|
||||
subsection: [
|
||||
['Mathematics', 'tabler:calculator'],
|
||||
['Physics', 'tabler:atom']
|
||||
]
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{ type: 'title', name: 'Lifestyle' },
|
||||
{ type: 'item', name: 'Blog', icon: 'tabler:file-text' },
|
||||
{
|
||||
type: 'item',
|
||||
name: 'Travel',
|
||||
icon: 'tabler:plane',
|
||||
subsection: [
|
||||
['Places', 'tabler:map-2'],
|
||||
['Trips', 'tabler:map-pin'],
|
||||
['Photos', 'tabler:photo']
|
||||
]
|
||||
},
|
||||
{ type: 'item', name: 'Achievements', icon: 'tabler:award' },
|
||||
{ type: 'item', name: 'Memory Archive', icon: 'tabler:archive' },
|
||||
{ type: 'divider' },
|
||||
{ type: 'title', name: 'Finance' },
|
||||
{
|
||||
type: 'item',
|
||||
name: 'Wallet',
|
||||
icon: 'tabler:currency-dollar',
|
||||
subsection: [
|
||||
['Balance', 'tabler:wallet'],
|
||||
['Transactions', 'tabler:arrows-exchange'],
|
||||
['Budgets', 'tabler:coin'],
|
||||
['Reports', 'tabler:chart-bar']
|
||||
]
|
||||
},
|
||||
{ type: 'item', name: 'Wish List', icon: 'tabler:heart' },
|
||||
{ type: 'divider' },
|
||||
{ type: 'title', name: 'Confidential' },
|
||||
{ type: 'item', name: 'Contacts', icon: 'tabler:users' },
|
||||
{ type: 'item', name: 'Passwords', icon: 'tabler:key' },
|
||||
{ type: 'divider' },
|
||||
{ type: 'title', name: 'Settings' },
|
||||
{ type: 'item', name: 'Settings', icon: 'tabler:settings' },
|
||||
{ type: 'item', name: 'Plugins', icon: 'tabler:plug' },
|
||||
{ type: 'item', name: 'Personalization', icon: 'tabler:palette' },
|
||||
{ type: 'item', name: 'Server Status', icon: 'tabler:server' },
|
||||
{ type: 'divider' },
|
||||
{ type: 'item', name: 'About', icon: 'tabler:info-circle' },
|
||||
{ type: 'item', name: 'Change Log', icon: 'tabler:file-text' }
|
||||
]
|
||||
|
||||
export default SIDEBAR_ITEMS
|
||||
@@ -1,40 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
import type Client from 'pocketbase'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Pocketbase from 'pocketbase'
|
||||
|
||||
export default function usePocketbase(): {
|
||||
pocketbase: any
|
||||
data: any
|
||||
error: any
|
||||
loading: boolean
|
||||
} {
|
||||
const [state, setState] = useState<{
|
||||
data: any
|
||||
error: any
|
||||
loading: boolean
|
||||
}>({
|
||||
data: null,
|
||||
error: null,
|
||||
loading: true
|
||||
})
|
||||
|
||||
const pocketbase = useRef<Client>()
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
setState(s => ({ ...s, loading: true }))
|
||||
try {
|
||||
pocketbase.current = new Pocketbase(
|
||||
import.meta.env.VITE_POCKETBASE_ENDPOINT as string
|
||||
)
|
||||
await pocketbase.current.collection('users').getList(1, 1)
|
||||
setState(s => ({ ...s, loading: false }))
|
||||
} catch (error) {
|
||||
setState(s => ({ ...s, error, loading: false }))
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return { pocketbase: pocketbase.current, ...state }
|
||||
}
|
||||
386
src/index.css
386
src/index.css
@@ -1,386 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Urbanist:wght@100;200;300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
font-family: 'Urbanist', sans-serif;
|
||||
scrollbar-color: rgba(64, 64, 64, 0.5) rgba(38, 38, 38, 0.5);
|
||||
scrollbar-width: thin;
|
||||
overscroll-behavior: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
body *:not(input) {
|
||||
@apply transition-all;
|
||||
}
|
||||
|
||||
.stroke-\[2px\] > path {
|
||||
stroke-width: 2.5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(38, 38, 38, 0.5);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:vertical {
|
||||
background: rgba(64, 64, 64, 0.5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(88, 88, 88, 0.5);
|
||||
}
|
||||
|
||||
input:autofill {
|
||||
background: rgb(38 38 38); /* or any other */
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
-webkit-box-shadow: 0 0 0 30px rgb(38, 38, 38) inset !important;
|
||||
-webkit-text-fill-color: rgba(250, 250, 250, 1) !important;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 4px solid rgb(115 115 115);
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
.small-loader-light {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgb(245 245 245);
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
.small-loader-dark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgb(38 38 38);
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
.w-color-editable-input {
|
||||
@apply !px-0;
|
||||
}
|
||||
|
||||
.w-color-editable-input input {
|
||||
box-shadow: none !important;
|
||||
font-size: 1rem !important;
|
||||
color: rgb(244 244 245) !important;
|
||||
font-weight: 500 !important;
|
||||
letter-spacing: 0.1rem !important;
|
||||
@apply text-center !rounded-lg !py-2 focus:outline-none;
|
||||
}
|
||||
|
||||
.w-color-editable-input span {
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
div[data-rmiz-modal-content] {
|
||||
@apply bg-neutral-100 dark:bg-neutral-900;
|
||||
}
|
||||
|
||||
progress::-webkit-progress-value {
|
||||
@apply !bg-custom-500;
|
||||
}
|
||||
|
||||
progress::-moz-progress-bar {
|
||||
@apply !bg-custom-500;
|
||||
}
|
||||
|
||||
.cm-editor * {
|
||||
font-family: 'Jetbrains Mono', monospace !important;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@apply !bg-neutral-200/50;
|
||||
}
|
||||
|
||||
.dark .cm-editor {
|
||||
@apply !bg-neutral-800;
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
@apply !bg-neutral-200;
|
||||
}
|
||||
|
||||
.dark .cm-gutters {
|
||||
@apply !bg-neutral-800;
|
||||
}
|
||||
|
||||
.theme-red {
|
||||
--color-custom-50: #ffebee;
|
||||
--color-custom-100: #ffcdd2;
|
||||
--color-custom-200: #ef9a9a;
|
||||
--color-custom-300: #e57373;
|
||||
--color-custom-400: #ef5350;
|
||||
--color-custom-500: #f44336;
|
||||
--color-custom-600: #e53935;
|
||||
--color-custom-700: #d32f2f;
|
||||
--color-custom-800: #c62828;
|
||||
--color-custom-900: #b71c1c;
|
||||
}
|
||||
|
||||
.theme-pink {
|
||||
--color-custom-50: #fce4ec;
|
||||
--color-custom-100: #f8bbd0;
|
||||
--color-custom-200: #f48fb1;
|
||||
--color-custom-300: #f06292;
|
||||
--color-custom-400: #ec407a;
|
||||
--color-custom-500: #e91e63;
|
||||
--color-custom-600: #d81b60;
|
||||
--color-custom-700: #c2185b;
|
||||
--color-custom-800: #ad1457;
|
||||
--color-custom-900: #880e4f;
|
||||
}
|
||||
|
||||
.theme-purple {
|
||||
--color-custom-50: #f3e5f5;
|
||||
--color-custom-100: #e1bee7;
|
||||
--color-custom-200: #ce93d8;
|
||||
--color-custom-300: #ba68c8;
|
||||
--color-custom-400: #ab47bc;
|
||||
--color-custom-500: #9c27b0;
|
||||
--color-custom-600: #8e24aa;
|
||||
--color-custom-700: #7b1fa2;
|
||||
--color-custom-800: #6a1b9a;
|
||||
--color-custom-900: #4a148c;
|
||||
}
|
||||
|
||||
.theme-deep-purple {
|
||||
--color-custom-50: #ede7f6;
|
||||
--color-custom-100: #d1c4e9;
|
||||
--color-custom-200: #b39ddb;
|
||||
--color-custom-300: #9575cd;
|
||||
--color-custom-400: #7e57c2;
|
||||
--color-custom-500: #673ab7;
|
||||
--color-custom-600: #5e35b1;
|
||||
--color-custom-700: #512da8;
|
||||
--color-custom-800: #4527a0;
|
||||
--color-custom-900: #311b92;
|
||||
}
|
||||
|
||||
.theme-indigo {
|
||||
--color-custom-50: #e8eaf6;
|
||||
--color-custom-100: #c5cae9;
|
||||
--color-custom-200: #9fa8da;
|
||||
--color-custom-300: #7986cb;
|
||||
--color-custom-400: #5c6bc0;
|
||||
--color-custom-500: #3f51b5;
|
||||
--color-custom-600: #3949ab;
|
||||
--color-custom-700: #303f9f;
|
||||
--color-custom-800: #283593;
|
||||
--color-custom-900: #1a237e;
|
||||
}
|
||||
|
||||
.theme-blue {
|
||||
--color-custom-50: #e3f2fd;
|
||||
--color-custom-100: #bbdefb;
|
||||
--color-custom-200: #90caf9;
|
||||
--color-custom-300: #64b5f6;
|
||||
--color-custom-400: #42a5f5;
|
||||
--color-custom-500: #2196f3;
|
||||
--color-custom-600: #1e88e5;
|
||||
--color-custom-700: #1976d2;
|
||||
--color-custom-800: #1565c0;
|
||||
--color-custom-900: #0d47a1;
|
||||
}
|
||||
|
||||
.theme-light-blue {
|
||||
--color-custom-50: #e1f5fe;
|
||||
--color-custom-100: #b3e5fc;
|
||||
--color-custom-200: #81d4fa;
|
||||
--color-custom-300: #4fc3f7;
|
||||
--color-custom-400: #29b6f6;
|
||||
--color-custom-500: #03a9f4;
|
||||
--color-custom-600: #039be5;
|
||||
--color-custom-700: #0288d1;
|
||||
--color-custom-800: #0277bd;
|
||||
--color-custom-900: #01579b;
|
||||
}
|
||||
|
||||
.theme-cyan {
|
||||
--color-custom-50: #e0f7fa;
|
||||
--color-custom-100: #b2ebf2;
|
||||
--color-custom-200: #80deea;
|
||||
--color-custom-300: #4dd0e1;
|
||||
--color-custom-400: #26c6da;
|
||||
--color-custom-500: #00bcd4;
|
||||
--color-custom-600: #00acc1;
|
||||
--color-custom-700: #0097a7;
|
||||
--color-custom-800: #00838f;
|
||||
--color-custom-900: #006064;
|
||||
}
|
||||
|
||||
.theme-teal {
|
||||
--color-custom-50: #e0f2f1;
|
||||
--color-custom-100: #b2dfdb;
|
||||
--color-custom-200: #80cbc4;
|
||||
--color-custom-300: #4db6ac;
|
||||
--color-custom-400: #26a69a;
|
||||
--color-custom-500: #009688;
|
||||
--color-custom-600: #00897b;
|
||||
--color-custom-700: #00796b;
|
||||
--color-custom-800: #00695c;
|
||||
--color-custom-900: #004d40;
|
||||
}
|
||||
|
||||
.theme-green {
|
||||
--color-custom-50: #e8f5e9;
|
||||
--color-custom-100: #c8e6c9;
|
||||
--color-custom-200: #a5d6a7;
|
||||
--color-custom-300: #81c784;
|
||||
--color-custom-400: #66bb6a;
|
||||
--color-custom-500: #4caf50;
|
||||
--color-custom-600: #43a047;
|
||||
--color-custom-700: #388e3c;
|
||||
--color-custom-800: #2e7d32;
|
||||
--color-custom-900: #1b5e20;
|
||||
}
|
||||
|
||||
.theme-light-green {
|
||||
--color-custom-50: #f1f8e9;
|
||||
--color-custom-100: #dcedc8;
|
||||
--color-custom-200: #c5e1a5;
|
||||
--color-custom-300: #aed581;
|
||||
--color-custom-400: #9ccc65;
|
||||
--color-custom-500: #8bc34a;
|
||||
--color-custom-600: #7cb342;
|
||||
--color-custom-700: #689f38;
|
||||
--color-custom-800: #558b2f;
|
||||
--color-custom-900: #33691e;
|
||||
}
|
||||
|
||||
.theme-lime {
|
||||
--color-custom-50: #f9fbe7;
|
||||
--color-custom-100: #f0f4c3;
|
||||
--color-custom-200: #e6ee9c;
|
||||
--color-custom-300: #dce775;
|
||||
--color-custom-400: #d4e157;
|
||||
--color-custom-500: #cddc39;
|
||||
--color-custom-600: #c0ca33;
|
||||
--color-custom-700: #afb42b;
|
||||
--color-custom-800: #9e9d24;
|
||||
--color-custom-900: #827717;
|
||||
}
|
||||
|
||||
.theme-yellow {
|
||||
--color-custom-50: #fffde7;
|
||||
--color-custom-100: #fff9c4;
|
||||
--color-custom-200: #fff59d;
|
||||
--color-custom-300: #fff176;
|
||||
--color-custom-400: #ffee58;
|
||||
--color-custom-500: #ffeb3b;
|
||||
--color-custom-600: #fdd835;
|
||||
--color-custom-700: #fbc02d;
|
||||
--color-custom-800: #f9a825;
|
||||
--color-custom-900: #f57f17;
|
||||
}
|
||||
|
||||
.theme-amber {
|
||||
--color-custom-50: #fff8e1;
|
||||
--color-custom-100: #ffecb3;
|
||||
--color-custom-200: #ffe082;
|
||||
--color-custom-300: #ffd54f;
|
||||
--color-custom-400: #ffca28;
|
||||
--color-custom-500: #ffc107;
|
||||
--color-custom-600: #ffb300;
|
||||
--color-custom-700: #ffa000;
|
||||
--color-custom-800: #ff8f00;
|
||||
--color-custom-900: #ff6f00;
|
||||
}
|
||||
|
||||
.theme-orange {
|
||||
--color-custom-50: #fff3e0;
|
||||
--color-custom-100: #ffe0b2;
|
||||
--color-custom-200: #ffcc80;
|
||||
--color-custom-300: #ffb74d;
|
||||
--color-custom-400: #ffa726;
|
||||
--color-custom-500: #ff9800;
|
||||
--color-custom-600: #fb8c00;
|
||||
--color-custom-700: #f57c00;
|
||||
--color-custom-800: #ef6c00;
|
||||
--color-custom-900: #e65100;
|
||||
}
|
||||
|
||||
.theme-deep-orange {
|
||||
--color-custom-50: #fbe9e7;
|
||||
--color-custom-100: #ffccbc;
|
||||
--color-custom-200: #ffab91;
|
||||
--color-custom-300: #ff8a65;
|
||||
--color-custom-400: #ff7043;
|
||||
--color-custom-500: #ff5722;
|
||||
--color-custom-600: #f4511e;
|
||||
--color-custom-700: #e64a19;
|
||||
--color-custom-800: #d84315;
|
||||
--color-custom-900: #bf360c;
|
||||
}
|
||||
|
||||
.theme-brown {
|
||||
--color-custom-50: #efebe9;
|
||||
--color-custom-100: #d7ccc8;
|
||||
--color-custom-200: #bcaaa4;
|
||||
--color-custom-300: #a1887f;
|
||||
--color-custom-400: #8d6e63;
|
||||
--color-custom-500: #795548;
|
||||
--color-custom-600: #6d4c41;
|
||||
--color-custom-700: #5d4037;
|
||||
--color-custom-800: #4e342e;
|
||||
--color-custom-900: #3e2723;
|
||||
}
|
||||
|
||||
.theme-grey {
|
||||
--color-custom-50: #fafafa;
|
||||
--color-custom-100: #f5f5f5;
|
||||
--color-custom-200: #eeeeee;
|
||||
--color-custom-300: #e0e0e0;
|
||||
--color-custom-400: #bdbdbd;
|
||||
--color-custom-500: #9e9e9e;
|
||||
--color-custom-600: #757575;
|
||||
--color-custom-700: #616161;
|
||||
--color-custom-800: #424242;
|
||||
--color-custom-900: #212121;
|
||||
}
|
||||
|
||||
::global {
|
||||
--toastify-color-light: var(--color-custom-500);
|
||||
}
|
||||
|
||||
.Toastify__progress-bar--default {
|
||||
background: var(--color-custom-500);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import 'react-tooltip/dist/react-tooltip.css'
|
||||
import 'react-toastify/dist/ReactToastify.css'
|
||||
import 'react-medium-image-zoom/dist/styles.css'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
|
||||
@@ -1,256 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import React from 'react'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
import { Icon } from '@iconify/react'
|
||||
|
||||
function Calendar(): React.JSX.Element {
|
||||
return (
|
||||
<section className="flex w-full flex-col overflow-y-auto px-12">
|
||||
<ModuleHeader
|
||||
title="Calendar"
|
||||
desc="Make sure you don't miss important event."
|
||||
/>
|
||||
<div className="mb-12 mt-8 flex min-h-0 w-full flex-1">
|
||||
<aside className="flex h-full flex-col gap-8">
|
||||
<section className="flex w-full flex-col gap-4 rounded-lg bg-neutral-50 p-8 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-6 flex items-center justify-between gap-2">
|
||||
<button className="rounded-lg p-4 text-neutral-100 transition-all hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50">
|
||||
<Icon icon="tabler:chevron-left" className="text-2xl" />
|
||||
</button>
|
||||
<div className="text-lg font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
November 2023
|
||||
</div>
|
||||
<button className="rounded-lg p-4 text-neutral-100 transition-all hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50">
|
||||
<Icon icon="tabler:chevron-right" className="text-2xl" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(day => (
|
||||
<div
|
||||
key={day}
|
||||
className="flex items-center justify-center text-sm text-neutral-500"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{Array(35)
|
||||
.fill(0)
|
||||
.map((_, index) =>
|
||||
(() => {
|
||||
const date = new Date()
|
||||
const firstDay =
|
||||
new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
1
|
||||
).getDay() - 1
|
||||
const lastDate = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
0
|
||||
).getDate()
|
||||
const lastDateOfPrevMonth =
|
||||
new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
0
|
||||
).getDate() - 1
|
||||
const actualIndex =
|
||||
firstDay > index
|
||||
? lastDateOfPrevMonth - firstDay + index + 2
|
||||
: index - firstDay + 1 > lastDate
|
||||
? index - lastDate - firstDay + 1
|
||||
: index - firstDay + 1
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`relative isolate flex flex-col items-center gap-1 text-sm ${
|
||||
firstDay > index || index - firstDay + 1 > lastDate
|
||||
? 'text-neutral-300 dark:text-neutral-600'
|
||||
: 'text-neutral-800 dark:text-neutral-100'
|
||||
} ${
|
||||
actualIndex === date.getDate()
|
||||
? "font-semibold after:absolute after:left-1/2 after:top-1/2 after:z-[-1] after:h-10 after:w-10 after:-translate-x-1/2 after:-translate-y-6 after:rounded-md after:border after:border-custom-500 after:bg-custom-500/10 after:content-['']"
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span>{actualIndex}</span>
|
||||
{(() => {
|
||||
const randomTrue = Math.random() > 0.7
|
||||
return randomTrue &&
|
||||
!(
|
||||
firstDay > index ||
|
||||
index - firstDay + 1 > lastDate
|
||||
) ? (
|
||||
<div className="h-0.5 w-3 rounded-full bg-rose-500" />
|
||||
) : (
|
||||
''
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="flex w-full flex-col gap-4 overflow-y-auto rounded-lg bg-neutral-50 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<h2 className="flex items-center gap-4 px-8 py-4 pt-8 text-sm font-semibold uppercase tracking-widest text-neutral-600 transition-all sm:px-12">
|
||||
Categories
|
||||
</h2>
|
||||
<ul className="flex flex-col overflow-y-hidden pb-8 hover:overflow-y-scroll">
|
||||
{[
|
||||
['Design', 'bg-green-500'],
|
||||
['Frontend', 'bg-blue-500'],
|
||||
['Backend', 'bg-yellow-500'],
|
||||
['Marketing', 'bg-red-500'],
|
||||
['Sales', 'bg-purple-500'],
|
||||
['Support', 'bg-pink-500']
|
||||
].map(([name, color], index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="relative flex items-center gap-6 px-4 font-medium text-neutral-400 transition-all"
|
||||
>
|
||||
<div className="flex w-full items-center gap-6 whitespace-nowrap rounded-lg p-4 hover:bg-neutral-200/50 dark:hover:bg-neutral-800">
|
||||
<span
|
||||
className={`block h-2 w-2 shrink-0 rounded-full ${color}`}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{Math.floor(Math.random() * 10)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
<div className="ml-12 flex h-full flex-1 flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<button className="rounded-lg p-4 text-neutral-500 transition-all hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50">
|
||||
<Icon icon="tabler:chevron-left" className="text-2xl" />
|
||||
</button>
|
||||
<button className="rounded-lg p-4 text-neutral-500 transition-all hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50">
|
||||
<Icon icon="tabler:chevron-right" className="text-2xl" />
|
||||
</button>
|
||||
<div className="ml-4 text-3xl font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
Nov 20 - 26, 2023
|
||||
</div>
|
||||
<span className="ml-4 rounded-full bg-custom-500/20 px-4 py-1.5 text-sm font-semibold text-custom-500 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)]">
|
||||
Week{' '}
|
||||
{(() => {
|
||||
const currentDate = new Date()
|
||||
const startDate = new Date(currentDate.getFullYear(), 0, 1)
|
||||
const days = Math.floor(
|
||||
(currentDate - startDate) / (24 * 60 * 60 * 1000)
|
||||
)
|
||||
|
||||
const weekNumber = Math.ceil(days / 7)
|
||||
|
||||
return weekNumber
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="rounded-lg p-4 text-neutral-100 transition-all hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50">
|
||||
<Icon icon="tabler:search" className="text-2xl" />
|
||||
</button>
|
||||
<button className="flex items-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] transition-all hover:bg-custom-600 dark:text-neutral-800">
|
||||
<Icon icon="tabler:plus" className="text-xl" />
|
||||
create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex h-full min-h-0 flex-1 flex-col">
|
||||
<div className="mb-1.5 flex w-full">
|
||||
<div className="flex w-20 shrink-0 flex-col items-center justify-center rounded-lg bg-neutral-50 py-4 text-neutral-500 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
GMT
|
||||
<span className="text-xl">+8</span>
|
||||
</div>
|
||||
{Array(7)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`ml-1.5 flex w-full items-center justify-center gap-2 rounded-lg bg-neutral-50 py-4 text-neutral-500 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50 ${
|
||||
index === 3 && 'bg-custom-500/20 text-custom-500'
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'][index]}
|
||||
</span>
|
||||
<span className="text-4xl font-semibold">{20 + index}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full flex-1 overflow-y-auto rounded-lg shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)]">
|
||||
<div className="h-full w-full divide-y divide-neutral-300 dark:divide-neutral-700">
|
||||
{Array(25)
|
||||
.fill(0)
|
||||
.map((_, hour) => (
|
||||
<div key={hour} className="flex h-24">
|
||||
<div className="relative h-full w-20 shrink-0 bg-neutral-50 text-neutral-100 dark:bg-neutral-800/50">
|
||||
{hour !== 24 && (
|
||||
<span className="absolute bottom-0 z-[9999] w-[90%] translate-y-1/2 bg-[#fafafa] pr-4 text-right dark:bg-[#1e1e1e]">
|
||||
{hour + 1 > 12 ? hour + 1 - 12 : hour + 1}{' '}
|
||||
{hour + 1 >= 12 ? 'PM' : 'AM'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{Array(7)
|
||||
.fill(0)
|
||||
.map((_, day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="relative w-full bg-neutral-50 dark:bg-neutral-800/50"
|
||||
>
|
||||
{day === 3 && hour === 1 && (
|
||||
<div className="absolute left-0 top-0 z-[9999] ml-1.5 h-96 w-[90%] overflow-hidden rounded-r-md bg-neutral-50 dark:bg-neutral-900">
|
||||
<div className="flex h-full w-full flex-col justify-between border-l-4 border-green-500 bg-green-500/20 px-3 py-4">
|
||||
<div className="flex flex-col">
|
||||
<Icon
|
||||
icon="tabler:plane-departure"
|
||||
className="mb-2 h-6 w-6 text-green-500"
|
||||
/>
|
||||
<span className="text-lg font-semibold text-green-500">
|
||||
Flight to Taiwan
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 text-lg font-medium">
|
||||
SIN
|
||||
<Icon
|
||||
icon="tabler:arrow-down"
|
||||
className="inline-block h-4 w-4 shrink-0 text-green-500"
|
||||
/>
|
||||
TPE
|
||||
</div>
|
||||
<span className="text-sm">FN: TR 898</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-green-500">
|
||||
01.00 AM - 05.00 AM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Calendar
|
||||
@@ -1,120 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-throw-literal */
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import React, { Fragment, useEffect, useState } from 'react'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
import { toast } from 'react-toastify'
|
||||
import Loading from '../../components/general/Loading'
|
||||
import Error from '../../components/general/Error'
|
||||
|
||||
interface IChangeLogVersion {
|
||||
version: string
|
||||
date_range: [string, string]
|
||||
entries: IChangeLogEntry[]
|
||||
}
|
||||
|
||||
interface IChangeLogEntry {
|
||||
id: string
|
||||
feature: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function Changelog(): React.ReactElement {
|
||||
const [data, setData] = useState<'loading' | 'error' | IChangeLogVersion[]>(
|
||||
'loading'
|
||||
)
|
||||
|
||||
function updateChangeLogEntries(): void {
|
||||
setData('loading')
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/change-log/list`)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
setData(data.data)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setData('error')
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateChangeLogEntries()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-full flex-1 flex-col overflow-y-scroll px-8 sm:px-12">
|
||||
<ModuleHeader
|
||||
title="Change Log"
|
||||
desc="All the changes made to this application will be listed here."
|
||||
/>
|
||||
{(() => {
|
||||
switch (data) {
|
||||
case 'loading':
|
||||
return <Loading />
|
||||
case 'error':
|
||||
return <Error message="Failed to fetch data." />
|
||||
default:
|
||||
return (
|
||||
<ul className="my-8 flex flex-col gap-4">
|
||||
{data.map(entry => (
|
||||
<li
|
||||
key={entry.version}
|
||||
className="flex flex-col gap-2 rounded-lg bg-neutral-50 p-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50"
|
||||
>
|
||||
<h3 className="mb-2 flex flex-col gap-2 text-2xl font-semibold sm:flex-row sm:items-end">
|
||||
Ver. {entry.version}{' '}
|
||||
<span className="mb-0.5 block text-sm">
|
||||
(
|
||||
{new Date(entry.date_range[0]).toLocaleDateString(
|
||||
'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}
|
||||
)}{' '}
|
||||
-{' '}
|
||||
{new Date(entry.date_range[1]).toLocaleDateString(
|
||||
'en-US',
|
||||
{
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
</h3>
|
||||
<ul className="flex list-inside list-disc flex-col gap-2 text-neutral-500 dark:text-neutral-400">
|
||||
{entry.entries.map(subEntry => (
|
||||
<li key={subEntry.id}>
|
||||
<span className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
{subEntry.feature}:
|
||||
</span>{' '}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: subEntry.description.replace(
|
||||
/<code>(.*?)<\/code>/,
|
||||
`
|
||||
<code class="inline-block rounded-md bg-neutral-200 p-1 px-1.5 font-['Jetbrains_Mono', text-sm shadow-[2px_2px_2px_rgba(0,0,0,0.05), dark:bg-neutral-800">$1</code>
|
||||
`
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Changelog
|
||||
@@ -1,445 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/member-delimiter-style */
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
import ActivityCalendar from 'react-activity-calendar'
|
||||
import { Icon } from '@iconify/react'
|
||||
import { Tooltip as ReactTooltip } from 'react-tooltip'
|
||||
|
||||
function HoursAndMinutesFromSeconds({
|
||||
seconds
|
||||
}: {
|
||||
seconds: number
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{Math.floor(seconds / 60) > 0 ? (
|
||||
<>
|
||||
{Math.floor(seconds / 60)}
|
||||
<span className="pl-1 text-3xl font-normal text-neutral-500">h</span>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)}{' '}
|
||||
{Math.floor(seconds % 60) > 0 ? (
|
||||
<>
|
||||
{Math.floor(seconds % 60)}
|
||||
<span className="pl-1 text-3xl font-normal text-neutral-500">m</span>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)}{' '}
|
||||
{seconds === 0 ? 'no time' : ''}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CodeTime(): React.JSX.Element {
|
||||
const [activities, setActivities] = useState<Array<{
|
||||
date: string
|
||||
count: number
|
||||
level: 0 | 1 | 2 | 3 | 4
|
||||
}> | null>(null)
|
||||
const [firstYear, setFirstYear] = useState(0)
|
||||
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear())
|
||||
const [stats, setStats] = useState<Record<string, number>>()
|
||||
const [lastForProjects, setLastForProjects] = useState<
|
||||
'24 hours' | '7 days' | '30 days'
|
||||
>('24 hours')
|
||||
const [lastForLanguages, setLastForLanguages] = useState<
|
||||
'24 hours' | '7 days' | '30 days'
|
||||
>('24 hours')
|
||||
const [topProjects, setTopProjects] = useState<
|
||||
Array<{ name: string; count: number }>
|
||||
>([])
|
||||
const [topLanguages, setTopLanguages] = useState<
|
||||
Array<{ name: string; count: number }>
|
||||
>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/code-time/activities?year=` +
|
||||
selectedYear
|
||||
)
|
||||
.then(async response => await response.json())
|
||||
.then(data => {
|
||||
setActivities(data.data.data)
|
||||
setFirstYear(data.data.firstYear)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/code-time/statistics`)
|
||||
.then(async response => await response.json())
|
||||
.then(data => {
|
||||
setStats(data.data)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/code-time/languages?last=` +
|
||||
lastForLanguages
|
||||
)
|
||||
.then(async response => await response.json())
|
||||
.then(data => {
|
||||
setTopLanguages(data.data)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [lastForLanguages])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/code-time/projects?last=` +
|
||||
lastForProjects
|
||||
)
|
||||
.then(async response => await response.json())
|
||||
.then(data => {
|
||||
setTopProjects(data.data)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [lastForProjects])
|
||||
|
||||
function switchSelectedYear(year: number): void {
|
||||
setSelectedYear(year)
|
||||
setActivities(null)
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/code-time/activities?year=` + year)
|
||||
.then(async response => await response.json())
|
||||
.then(data => {
|
||||
setActivities(data.data.data.length > 0 ? data.data.data : null)
|
||||
setFirstYear(data.data.firstYear)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-full flex-1 flex-col px-12">
|
||||
<ModuleHeader
|
||||
title="Code Time"
|
||||
desc="See how much time you spend grinding code."
|
||||
/>
|
||||
<div className="mt-8 flex min-h-0 w-full flex-1 flex-col items-center overflow-y-auto">
|
||||
{stats !== undefined && (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<h1 className="mb-2 flex items-center gap-2 text-2xl font-semibold">
|
||||
<Icon icon="tabler:chart-bar" className="text-3xl" />
|
||||
<span className="ml-2">Statistics</span>
|
||||
</h1>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{Object.entries(stats).map(([key, value], index) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex w-full flex-col items-start gap-2 rounded-lg bg-neutral-50 p-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50"
|
||||
>
|
||||
<div className="flex rounded-lg bg-neutral-200/70 p-4 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800">
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
'Most time spent': 'tabler:coffee',
|
||||
'Total time spent': 'tabler:clock',
|
||||
'Average time spent': 'tabler:wave-saw-tool',
|
||||
'Longest streak': 'tabler:flame',
|
||||
'Current streak': 'tabler:flame'
|
||||
}[key]!
|
||||
}
|
||||
className={`text-3xl ${
|
||||
index === 3
|
||||
? 'text-orange-300'
|
||||
: 'text-neutral-500 dark:text-neutral-100'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-lg text-neutral-500">{key}</div>
|
||||
<div className="text-4xl font-semibold text-neutral-800">
|
||||
{index < 3 ? (
|
||||
<HoursAndMinutesFromSeconds seconds={value} />
|
||||
) : (
|
||||
<>
|
||||
{value}
|
||||
<span className="pl-1 text-3xl font-normal text-neutral-500">
|
||||
days
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-16 flex w-full flex-col gap-6">
|
||||
<h1 className="mb-2 flex items-center gap-2 text-2xl font-semibold">
|
||||
<Icon icon="tabler:activity" className="text-3xl" />
|
||||
<span className="ml-2">Activities Calendar</span>
|
||||
</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={`flex flex-1 items-center ${
|
||||
Array.isArray(activities) ? 'justify-start' : 'justify-center'
|
||||
}`}
|
||||
>
|
||||
{Array.isArray(activities) ? (
|
||||
<ActivityCalendar
|
||||
data={activities}
|
||||
blockSize={14}
|
||||
blockMargin={6}
|
||||
labels={{
|
||||
totalCount: `${
|
||||
Math.floor(
|
||||
activities.reduce((a, b) => a + b.count, 0) / 60
|
||||
) > 0
|
||||
? `${Math.floor(
|
||||
activities.reduce((a, b) => a + b.count, 0) / 60
|
||||
)} hours`
|
||||
: ''
|
||||
} ${
|
||||
Math.floor(
|
||||
activities.reduce((a, b) => a + b.count, 0) % 60
|
||||
) > 0
|
||||
? `${Math.floor(
|
||||
activities.reduce((a, b) => a + b.count, 0) % 60
|
||||
)} minutes`
|
||||
: ''
|
||||
} ${
|
||||
activities.reduce((a, b) => a + b.count, 0) === 0
|
||||
? 'no time'
|
||||
: ''
|
||||
} spent on {{year}}`
|
||||
}}
|
||||
renderBlock={(block, activity) =>
|
||||
React.cloneElement(block, {
|
||||
'data-tooltip-id': 'react-tooltip',
|
||||
'data-tooltip-html': `${
|
||||
Math.floor(activity.count / 60) > 0
|
||||
? `${Math.floor(activity.count / 60)} hours`
|
||||
: ''
|
||||
} ${
|
||||
Math.floor(activity.count % 60) > 0
|
||||
? `${Math.floor(activity.count % 60)} minutes`
|
||||
: ''
|
||||
} ${activity.count === 0 ? 'no time' : ''} spent on ${
|
||||
activity.date
|
||||
}`.trim()
|
||||
})
|
||||
}
|
||||
theme={{
|
||||
// dark: ['rgb(38, 38, 38)', 'rgb(20, 184, 166)'],
|
||||
dark: ['rgb(229, 229, 229)', 'rgb(20, 184, 166)']
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Icon icon="svg-spinners:180-ring" className="text-4xl" />
|
||||
)}
|
||||
</div>
|
||||
<ReactTooltip id="react-tooltip" />
|
||||
{firstYear && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array(new Date().getFullYear() - firstYear + 1)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
switchSelectedYear(firstYear + index)
|
||||
}}
|
||||
className={`flex items-start gap-2 rounded-lg p-4 px-8 sm:px-12 font-medium ${
|
||||
selectedYear === firstYear + index
|
||||
? 'bg-neutral-200 font-semibold text-neutral-800 dark:bg-neutral-700/50 dark:text-neutral-100'
|
||||
: 'text-neutral-400 hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50'
|
||||
}`}
|
||||
>
|
||||
<span>{firstYear + index}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-16 flex w-full flex-col gap-6">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h1 className="mb-2 flex items-center gap-2 text-2xl font-semibold">
|
||||
<Icon icon="tabler:clipboard" className="text-3xl" />
|
||||
<span className="ml-2">
|
||||
Projects You've Spent Most Time Doing
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium tracking-wider">in the last</p>
|
||||
<div className="flex gap-2 rounded-lg p-2">
|
||||
{['24 hours', '7 days', '30 days'].map((last, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
setLastForProjects(
|
||||
last as '24 hours' | '7 days' | '30 days'
|
||||
)
|
||||
}}
|
||||
className={`rounded-md p-4 px-6 tracking-wide ${
|
||||
lastForProjects === last
|
||||
? 'bg-neutral-200 font-semibold text-neutral-800 dark:bg-neutral-700/50 dark:text-neutral-100'
|
||||
: 'text-neutral-400 hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50'
|
||||
}`}
|
||||
>
|
||||
{last}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
{Object.keys(topProjects).length > 0 &&
|
||||
Object.entries(topProjects)
|
||||
.slice(0, 5)
|
||||
.map(([key, value], index) => (
|
||||
<div
|
||||
className={`h-6 border ${index === 0 && 'rounded-l-lg'} ${
|
||||
index === 4 && 'rounded-r-lg'
|
||||
} ${
|
||||
[
|
||||
'bg-red-500/20 border-red-500',
|
||||
'bg-orange-500/20 border-orange-500',
|
||||
'bg-yellow-500/20 border-yellow-500',
|
||||
'bg-blue-500/20 border-blue-500',
|
||||
'bg-emerald-500/20 border-emerald-500'
|
||||
][index]
|
||||
}`}
|
||||
key={key}
|
||||
style={{
|
||||
width: `${Math.round(
|
||||
(value /
|
||||
Object.entries(topProjects)
|
||||
.slice(0, 5)
|
||||
.reduce((a, b) => a + b[1], 0)) *
|
||||
100
|
||||
)}%`
|
||||
}}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-4">
|
||||
{Object.keys(topProjects).length > 0 &&
|
||||
Object.entries(topProjects)
|
||||
.slice(0, 5)
|
||||
.map(([key, value], index) => (
|
||||
<li
|
||||
key={key}
|
||||
className="relative flex items-center justify-between gap-4 rounded-lg bg-neutral-50 p-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50"
|
||||
>
|
||||
<div className="flex items-center gap-4 text-lg font-medium text-neutral-800">
|
||||
<div
|
||||
className={`h-4 w-4 rounded-md border ${
|
||||
[
|
||||
'bg-red-500/20 border-red-500',
|
||||
'bg-orange-500/20 border-orange-500',
|
||||
'bg-yellow-500/20 border-yellow-500',
|
||||
'bg-blue-500/20 border-blue-500',
|
||||
'bg-emerald-500/20 border-emerald-500'
|
||||
][index]
|
||||
} rounded-full`}
|
||||
></div>
|
||||
{key}
|
||||
</div>
|
||||
<div className="text-3xl font-semibold text-neutral-800">
|
||||
<HoursAndMinutesFromSeconds seconds={value} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mb-6 mt-16 flex w-full flex-col gap-6">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h1 className="mb-2 flex items-center gap-2 text-2xl font-semibold">
|
||||
<Icon icon="tabler:code" className="text-3xl" />
|
||||
<span className="ml-2">
|
||||
Languages You've Spent Most Time Using
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium tracking-wider">in the last</p>
|
||||
<div className="flex gap-2 rounded-lg p-2">
|
||||
{['24 hours', '7 days', '30 days'].map((last, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
setLastForLanguages(
|
||||
last as '24 hours' | '7 days' | '30 days'
|
||||
)
|
||||
}}
|
||||
className={`rounded-md p-4 px-6 tracking-wide hover:bg-neutral-700/50 ${
|
||||
lastForLanguages === last
|
||||
? 'bg-neutral-200 font-semibold text-neutral-800 dark:bg-neutral-700/50 dark:text-neutral-100'
|
||||
: 'text-neutral-400 hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50'
|
||||
}`}
|
||||
>
|
||||
{last}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full">
|
||||
{Object.keys(topLanguages).length > 0 &&
|
||||
Object.entries(topLanguages)
|
||||
.slice(0, 5)
|
||||
.map(([key, value], index) => (
|
||||
<div
|
||||
className={`h-6 border ${index === 0 && 'rounded-l-lg'} ${
|
||||
index === 4 && 'rounded-r-lg'
|
||||
} ${
|
||||
[
|
||||
'bg-red-500/20 border-red-500',
|
||||
'bg-orange-500/20 border-orange-500',
|
||||
'bg-yellow-500/20 border-yellow-500',
|
||||
'bg-blue-500/20 border-blue-500',
|
||||
'bg-emerald-500/20 border-emerald-500'
|
||||
][index]
|
||||
}`}
|
||||
key={key}
|
||||
style={{
|
||||
width: `${Math.round(
|
||||
(value /
|
||||
Object.entries(topLanguages)
|
||||
.slice(0, 5)
|
||||
.reduce((a, b) => a + b[1], 0)) *
|
||||
100
|
||||
)}%`
|
||||
}}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-4">
|
||||
{Object.keys(topLanguages).length > 0 &&
|
||||
Object.entries(topLanguages)
|
||||
.slice(0, 5)
|
||||
.map(([key, value], index) => (
|
||||
<li
|
||||
key={key}
|
||||
className="relative flex items-center justify-between gap-4 rounded-lg bg-neutral-50 p-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50"
|
||||
>
|
||||
<div className="flex items-center gap-4 text-lg font-medium text-neutral-800">
|
||||
<div
|
||||
className={`h-4 w-4 rounded-md border ${
|
||||
[
|
||||
'bg-red-500/20 border-red-500',
|
||||
'bg-orange-500/20 border-orange-500',
|
||||
'bg-yellow-500/20 border-yellow-500',
|
||||
'bg-blue-500/20 border-blue-500',
|
||||
'bg-emerald-500/20 border-emerald-500'
|
||||
][index]
|
||||
} rounded-full`}
|
||||
></div>
|
||||
{key}
|
||||
</div>
|
||||
<div className="text-3xl font-semibold text-neutral-800">
|
||||
<HoursAndMinutesFromSeconds seconds={value} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import React, { useContext } from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
|
||||
import StorageStatus from './modules/StorageStatus'
|
||||
import CodeTime from './modules/CodeTime'
|
||||
import WalletBalance from './modules/WalletBalance'
|
||||
import TodaysEvent from './modules/TodaysEvent'
|
||||
import Calendar from './modules/Calendar'
|
||||
import TodoList from './modules/TodoList'
|
||||
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
import { AuthContext } from '../../providers/AuthProvider'
|
||||
|
||||
ChartJS.register(
|
||||
ArcElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Filler
|
||||
)
|
||||
|
||||
function Dashboard(): React.JSX.Element {
|
||||
const { userData } = useContext(AuthContext)
|
||||
|
||||
return (
|
||||
<section className="flex w-full flex-col overflow-y-auto px-12">
|
||||
<div className="mb-8 flex w-full flex-col">
|
||||
<ModuleHeader
|
||||
title="Dashboard"
|
||||
desc={
|
||||
<>
|
||||
Good to see you here,{' '}
|
||||
<span className="text-custom-500">{userData?.name}</span>!
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="mt-6 grid w-full grid-cols-4 grid-rows-3 gap-6">
|
||||
<StorageStatus />
|
||||
<CodeTime />
|
||||
<TodaysEvent />
|
||||
<WalletBalance />
|
||||
<TodoList />
|
||||
<Calendar />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
@@ -1,87 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import React from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
|
||||
export default function Calendar(): React.JSX.Element {
|
||||
return (
|
||||
<section className="col-span-2 row-span-1 flex w-full flex-col gap-4 rounded-lg bg-neutral-50 p-8 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<h1 className="mb-2 flex items-center gap-2 text-xl font-semibold">
|
||||
<Icon icon="tabler:calendar" className="text-2xl" />
|
||||
<span className="ml-2">Calendar</span>
|
||||
</h1>
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<button className="text-neutral-100 rounded-lg p-4 transition-all hover:bg-neutral-200 dark:hover:bg-neutral-700/50">
|
||||
<Icon icon="tabler:chevron-left" className="text-2xl" />
|
||||
</button>
|
||||
<div className="text-lg font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
November 2023
|
||||
</div>
|
||||
<button className="text-neutral-100 rounded-lg p-4 transition-all hover:bg-neutral-200 dark:hover:bg-neutral-700/50">
|
||||
<Icon icon="tabler:chevron-right" className="text-2xl" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-4">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(day => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-neutral-100 flex items-center justify-center text-sm"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{Array(35)
|
||||
.fill(0)
|
||||
.map((_, index) =>
|
||||
(() => {
|
||||
const date = new Date()
|
||||
const firstDay =
|
||||
new Date(date.getFullYear(), date.getMonth(), 1).getDay() - 1
|
||||
const lastDate = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
0
|
||||
).getDate()
|
||||
const lastDateOfPrevMonth =
|
||||
new Date(date.getFullYear(), date.getMonth(), 0).getDate() - 1
|
||||
const actualIndex =
|
||||
firstDay > index
|
||||
? lastDateOfPrevMonth - firstDay + index + 2
|
||||
: index - firstDay + 1 > lastDate
|
||||
? index - lastDate - firstDay + 1
|
||||
: index - firstDay + 1
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`relative isolate flex flex-col items-center gap-1 text-sm ${
|
||||
firstDay > index || index - firstDay + 1 > lastDate
|
||||
? 'text-neutral-300'
|
||||
: 'text-neutral-800 dark:text-neutral-100'
|
||||
} ${
|
||||
actualIndex === date.getDate()
|
||||
? "after:absolute after:left-1/2 after:top-1/2 after:z-[-1] after:h-10 after:w-10 after:-translate-x-1/2 after:-translate-y-1/2 after:rounded-md after:border after:border-custom-500 after:bg-custom-500/10 after:content-['']"
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span>{actualIndex}</span>
|
||||
{(() => {
|
||||
const randomTrue = Math.random() > 0.7
|
||||
return randomTrue &&
|
||||
!(
|
||||
firstDay > index || index - firstDay + 1 > lastDate
|
||||
) ? (
|
||||
<div className="h-0.5 w-3 rounded-full bg-rose-500" />
|
||||
) : (
|
||||
''
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
import { type ScriptableContext } from 'chart.js'
|
||||
import { Line } from 'react-chartjs-2'
|
||||
|
||||
function msToTime(ms: number): string {
|
||||
const seconds = (ms / 1000).toFixed(1)
|
||||
const minutes = (ms / (1000 * 60)).toFixed(1)
|
||||
const hours = (ms / (1000 * 60 * 60)).toFixed(1)
|
||||
const days = (ms / (1000 * 60 * 60 * 24)).toFixed(1)
|
||||
if (parseFloat(seconds) < 60) return seconds + ' Sec'
|
||||
else if (parseFloat(minutes) < 60) return minutes + ' Min'
|
||||
else if (parseFloat(hours) < 24) return hours + ' Hrs'
|
||||
else return days + ' Days'
|
||||
}
|
||||
|
||||
const raw = [
|
||||
['2023-11-23T00:00:00Z', 12180000],
|
||||
['2023-11-22T00:00:00Z', 7620000],
|
||||
['2023-11-21T00:00:00Z', 16200000],
|
||||
['2023-11-20T00:00:00Z', 18360000],
|
||||
['2023-11-18T00:00:00Z', 2940000],
|
||||
['2023-11-17T00:00:00Z', 16020000],
|
||||
['2023-11-16T00:00:00Z', 2040000],
|
||||
['2023-11-15T00:00:00Z', 3480000],
|
||||
['2023-11-14T00:00:00Z', 7200000],
|
||||
['2023-11-11T00:00:00Z', 5580000],
|
||||
['2023-11-10T00:00:00Z', 8640000],
|
||||
['2023-11-08T00:00:00Z', 480000],
|
||||
['2023-11-06T00:00:00Z', 420000],
|
||||
['2023-11-05T00:00:00Z', 9960000],
|
||||
['2023-11-04T00:00:00Z', 13620000],
|
||||
['2023-11-03T00:00:00Z', 5940000],
|
||||
['2023-11-02T00:00:00Z', 13500000],
|
||||
['2023-11-01T00:00:00Z', 11640000],
|
||||
['2023-10-31T00:00:00Z', 16500000],
|
||||
['2023-10-29T00:00:00Z', 13800000],
|
||||
['2023-10-28T00:00:00Z', 22800000],
|
||||
['2023-10-27T00:00:00Z', 7380000],
|
||||
['2023-10-26T00:00:00Z', 8820000],
|
||||
['2023-10-25T00:00:00Z', 960000],
|
||||
['2023-10-24T00:00:00Z', 10440000]
|
||||
].reverse()
|
||||
|
||||
const data2 = {
|
||||
labels: raw.map(([date]) =>
|
||||
new Date(date).toDateString().split(' ').slice(1, 3).join(' ')
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Code time',
|
||||
data: raw.map(([, value]) => (value as number) / 3600000),
|
||||
backgroundColor: (context: ScriptableContext<'line'>) => {
|
||||
const ctx = context.chart.ctx
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 250)
|
||||
gradient.addColorStop(0, 'rgba(20,184,166,0.2)')
|
||||
gradient.addColorStop(1, 'rgba(20,184,166,0)')
|
||||
return gradient
|
||||
},
|
||||
fill: 'origin',
|
||||
borderColor: 'rgba(20, 184, 166, 1)',
|
||||
borderWidth: 1,
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const options2 = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context: any) {
|
||||
let label = context.dataset.label || ''
|
||||
|
||||
if (label) {
|
||||
label += ': '
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += msToTime(context.parsed.y * 3600000)
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
type: 'time',
|
||||
callback: function (label: number) {
|
||||
return Math.round(label) + 'h'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
border: {
|
||||
color: 'rgba(163, 163, 163, 0.5)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
border: {
|
||||
color: 'rgba(163, 163, 163, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function CodeTime(): React.JSX.Element {
|
||||
return (
|
||||
<section className="col-span-2 flex h-full w-full flex-col gap-4 rounded-lg bg-neutral-50 p-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<h1 className="mb-2 flex items-center gap-2 text-xl font-semibold">
|
||||
<Icon icon="tabler:chart-line" className="text-2xl" />
|
||||
<span className="ml-2">Code Time</span>
|
||||
</h1>
|
||||
<div className="flex h-72 w-full items-center justify-center">
|
||||
<Line data={data2} options={options2} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Icon } from '@iconify/react'
|
||||
import React from 'react'
|
||||
import { Doughnut } from 'react-chartjs-2'
|
||||
|
||||
const data = {
|
||||
labels: ['Images', 'Videos', 'Musics', 'Documents'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Storage occupation',
|
||||
data: [19, 12, 3, 5],
|
||||
backgroundColor: [
|
||||
'rgba(244, 63, 94, 0.2)',
|
||||
'rgba(245 ,158, 11, 0.2)',
|
||||
'rgba(59, 130, 246, 0.2)',
|
||||
'rgba(34, 197, 94, 0.2)'
|
||||
],
|
||||
borderColor: [
|
||||
'rgba(244, 63, 94, 1)',
|
||||
'rgba(245, 158, 11, 1)',
|
||||
'rgba(59, 130, 246, 1)',
|
||||
'rgba(34, 197, 94, 1)'
|
||||
],
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const options = {
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context: any) {
|
||||
let label = context.dataset.label || ''
|
||||
|
||||
if (label) {
|
||||
label += ': '
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(context.parsed / 100)
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
labels: {
|
||||
color: 'rgb(18, 18, 18)'
|
||||
},
|
||||
position: 'bottom' as const
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function StorageStatus(): React.JSX.Element {
|
||||
return (
|
||||
<section className="col-span-1 flex w-full flex-col gap-4 rounded-lg bg-neutral-50 p-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<h1 className="mb-2 flex items-center gap-2 text-xl font-semibold">
|
||||
<Icon icon="tabler:server" className="text-2xl" />
|
||||
<span className="ml-2">Storage Status</span>
|
||||
</h1>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Doughnut data={data} options={options} />
|
||||
</div>
|
||||
<p className="text-center text-lg font-medium">520 GB of 1 TB used</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
|
||||
export default function TodaysEvent(): React.JSX.Element {
|
||||
return (
|
||||
<section className="col-span-1 flex w-full flex-col gap-4 rounded-lg bg-neutral-50 p-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<h1 className="mb-2 flex items-center gap-2 text-xl font-semibold">
|
||||
<Icon icon="tabler:calendar" className="text-2xl" />
|
||||
<span className="ml-2">Today's Event</span>
|
||||
</h1>
|
||||
<ul className="flex h-full flex-col gap-4">
|
||||
<li className="flex flex-1 items-center justify-between gap-4 rounded-lg bg-neutral-100 p-4 shadow-[4px_4px_10px_rgba(0,0,0,0.1)] dark:bg-neutral-800">
|
||||
<div className="h-full w-1.5 rounded-full bg-rose-500" />
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
Coldplay's concert
|
||||
</div>
|
||||
<div className="text-sm text-neutral-400">8:00 PM</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex flex-1 items-center justify-between gap-4 rounded-lg bg-neutral-100 p-4 shadow-[4px_4px_10px_rgba(0,0,0,0.1)] dark:bg-neutral-800">
|
||||
<div className="h-full w-1.5 rounded-full bg-purple-500" />
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
Meeting with client
|
||||
</div>
|
||||
<div className="text-sm text-neutral-400">10:00 PM</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex flex-1 items-center justify-between gap-4 rounded-lg bg-neutral-100 p-4 shadow-[4px_4px_10px_rgba(0,0,0,0.1)] dark:bg-neutral-800">
|
||||
<div className="h-full w-1.5 rounded-full bg-purple-500" />
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
Deadline for project
|
||||
</div>
|
||||
<div className="text-sm text-neutral-400">11:59 PM</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
|
||||
export default function TodoList(): React.JSX.Element {
|
||||
return (
|
||||
<section className="col-span-2 row-span-2 flex w-full flex-col gap-4 rounded-lg bg-neutral-50 p-8 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<h1 className="mb-2 flex items-center gap-2 text-xl font-semibold">
|
||||
<Icon icon="tabler:clipboard-list" className="text-2xl" />
|
||||
<span className="ml-2">Todo List</span>
|
||||
</h1>
|
||||
<ul className="flex flex-col gap-4">
|
||||
<li className="flex items-center justify-between gap-4 rounded-lg border-l-4 border-indigo-500 bg-neutral-100 p-4 px-6 shadow-[4px_4px_10px_rgba(0,0,0,0.1)] dark:bg-neutral-800">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
Buy groceries
|
||||
</div>
|
||||
<div className="text-sm text-rose-500">
|
||||
10:00 AM, 23 Nov 2023 (overdue 8 hours)
|
||||
</div>
|
||||
</div>
|
||||
<button className="h-6 w-6 rounded-full border-2 border-neutral-400 transition-all hover:border-orange-500" />
|
||||
</li>
|
||||
<li className="flex items-center justify-between gap-4 rounded-lg border-l-4 border-orange-500 bg-neutral-100 p-4 px-6 shadow-[4px_4px_10px_rgba(0,0,0,0.1)] dark:bg-neutral-800">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
Do homework
|
||||
</div>
|
||||
<div className="text-sm text-neutral-400">
|
||||
00:00 AM, 31 Jan 2024
|
||||
</div>
|
||||
</div>
|
||||
<button className="h-6 w-6 rounded-full border-2 border-neutral-400 transition-all hover:border-orange-500" />
|
||||
</li>
|
||||
<li className="flex items-center justify-between gap-4 rounded-lg border-l-4 border-orange-500 bg-neutral-100 p-4 px-6 shadow-[4px_4px_10px_rgba(0,0,0,0.1)] dark:bg-neutral-800">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
Start doing revision for SPM Sejarah
|
||||
</div>
|
||||
<div className="text-sm text-neutral-400">
|
||||
00:00 AM, 31 Jan 2024
|
||||
</div>
|
||||
</div>
|
||||
<button className="h-6 w-6 rounded-full border-2 border-neutral-400 transition-all hover:border-orange-500" />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Icon } from '@iconify/react'
|
||||
import React from 'react'
|
||||
|
||||
export default function WalletBalance(): React.JSX.Element {
|
||||
return (
|
||||
<section className="col-span-2 row-span-1 flex w-full flex-col gap-4 rounded-lg bg-neutral-50 p-8 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<h1 className="mb-2 flex items-center gap-2 text-xl font-semibold">
|
||||
<Icon icon="tabler:wallet" className="text-2xl" />
|
||||
<span className="ml-2">Wallet Balance</span>
|
||||
</h1>
|
||||
<ul className="flex flex-col gap-4">
|
||||
<li className="flex items-center justify-between gap-4 rounded-lg bg-neutral-100 p-4 pl-6 shadow-[4px_4px_10px_rgba(0,0,0,0.1)] transition-all hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-700/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon icon="tabler:cash" className="h-6 w-6" />
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
Cash
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500">RM 520.00</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="rounded-lg p-4 text-neutral-400 transition-all">
|
||||
<Icon icon="tabler:chevron-right" className="text-2xl" />
|
||||
</button>
|
||||
</li>
|
||||
<li className="flex items-center justify-between gap-4 rounded-lg bg-neutral-100 p-4 pl-6 shadow-[4px_4px_10px_rgba(0,0,0,0.1)] transition-all hover:bg-neutral-200 dark:bg-neutral-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon icon="tabler:device-mobile" className="h-6 w-6" />
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
Touch N' Go e-Wallet
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500">RM 128.00</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="rounded-lg p-4 text-neutral-400 transition-all">
|
||||
<Icon icon="tabler:chevron-right" className="text-2xl" />
|
||||
</button>
|
||||
</li>
|
||||
<li className="flex items-center justify-between gap-4 rounded-lg bg-neutral-100 p-4 pl-6 shadow-[4px_4px_10px_rgba(0,0,0,0.1)] transition-all hover:bg-neutral-200 dark:bg-neutral-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon icon="tabler:building-bank" className="h-6 w-6" />
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
Bank Account
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500">RM 12,487.00</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="rounded-lg p-4 text-neutral-400 transition-all">
|
||||
<Icon icon="tabler:chevron-right" className="text-2xl" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
function CardSet(): React.JSX.Element {
|
||||
return (
|
||||
<section className="relative flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-y-auto px-12">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
to="/flashcards"
|
||||
className="mb-2 flex w-min items-center gap-2 rounded-lg p-2 pl-0 pr-4 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Icon icon="tabler:chevron-left" className="text-xl" />
|
||||
<span className="whitespace-nowrap text-lg font-medium">Go back</span>
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="flex items-center text-3xl font-semibold ">
|
||||
华文年中考注释
|
||||
<span className="ml-4 rounded-full bg-custom-500/20 px-4 py-1.5 text-sm font-semibold text-custom-500">
|
||||
40 cards
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button className="rounded-lg p-4 text-neutral-100 transition-all hover:bg-neutral-800 hover:text-neutral-100">
|
||||
<Icon icon="tabler:border-corners" className="text-2xl" />
|
||||
</button>
|
||||
<button className="rounded-lg p-4 text-neutral-100 transition-all hover:bg-neutral-800 hover:text-neutral-100">
|
||||
<Icon icon="tabler:dots-vertical" className="text-2xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-center justify-center">
|
||||
<div className="flex h-1/2 w-3/5 items-center justify-center gap-4">
|
||||
<button className="flex h-full shrink-0 items-center justify-center p-4">
|
||||
<Icon icon="tabler:chevron-left" className="text-3xl" />
|
||||
</button>
|
||||
<div className="stack h-full w-full">
|
||||
<div className="card h-full bg-custom-500 text-neutral-800 shadow-md">
|
||||
<div className="card-body flex h-full flex-col">
|
||||
<h2 className="card-title text-custom-700">#1</h2>
|
||||
<div className="flex w-full flex-1 flex-col items-center justify-center text-3xl">
|
||||
垂白上(偻)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card h-full bg-custom-700 text-neutral-800 !opacity-100 shadow"></div>
|
||||
<div className="card h-full bg-custom-900 text-neutral-800 !opacity-100 shadow-sm"></div>
|
||||
</div>
|
||||
<button className="flex h-full shrink-0 items-center justify-center p-4">
|
||||
<Icon icon="tabler:chevron-right" className="text-3xl" />
|
||||
</button>
|
||||
</div>
|
||||
<button className="mt-12 flex w-1/2 items-center justify-center gap-2 rounded-lg bg-neutral-200 p-4 text-lg font-medium shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800">
|
||||
<Icon icon="tabler:bulb" className="text-2xl" />
|
||||
Show answer
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default CardSet
|
||||
@@ -1,90 +0,0 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
|
||||
export default function Flashcards(): React.JSX.Element {
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-full flex-1 flex-col px-12">
|
||||
<ModuleHeader
|
||||
title="Flashcards"
|
||||
desc="Memorizing could be a pain, but not with flashcards."
|
||||
/>
|
||||
<div className="mt-8 flex min-h-0 w-full flex-1 flex-col">
|
||||
<search className="flex w-full items-center gap-4 rounded-lg bg-neutral-50 p-4 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<Icon icon="tabler:search" className="h-5 w-5 text-neutral-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search flashcard sets ..."
|
||||
className="w-full bg-transparent text-neutral-500 placeholder:text-neutral-400 focus:outline-none"
|
||||
/>
|
||||
</search>
|
||||
<div className="mt-6 grid w-full grid-cols-3 gap-6 overflow-y-auto pb-12">
|
||||
{Array(5)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Link
|
||||
to={`/flashcards/${index}`}
|
||||
key={index}
|
||||
className="relative flex flex-col justify-start gap-6 rounded-lg bg-neutral-50 p-8 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] hover:bg-neutral-200/50 dark:bg-neutral-800/50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-neutral-400">
|
||||
{faker.datatype.number(100)} cards
|
||||
</p>
|
||||
<div className="text-xl font-medium text-neutral-800 dark:text-neutral-100">
|
||||
{faker.commerce.productName()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<progress
|
||||
className="progress h-2 w-full rounded-lg bg-neutral-200 dark:bg-neutral-700"
|
||||
value={faker.datatype.number(100)}
|
||||
max="100"
|
||||
></progress>
|
||||
<p className="text-sm font-medium text-neutral-400">
|
||||
{faker.datatype.number(100)}% complete
|
||||
</p>
|
||||
</div>
|
||||
<button className="absolute right-4 top-4 rounded-md p-2 text-neutral-500 hover:bg-neutral-700/30 hover:text-neutral-100">
|
||||
<Icon icon="tabler:dots-vertical" className="h-5 w-5" />
|
||||
</button>
|
||||
</Link>
|
||||
))}
|
||||
{Array(4)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Link
|
||||
to={`/idea-box/${index}`}
|
||||
key={index}
|
||||
className="relative flex flex-col justify-start gap-6 rounded-lg bg-neutral-50 p-8 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] hover:bg-neutral-200/50 dark:bg-neutral-800/50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-neutral-400">
|
||||
{faker.datatype.number(100)} cards
|
||||
</p>
|
||||
<div className="text-xl font-medium text-neutral-800 dark:text-neutral-100">
|
||||
{faker.commerce.productName()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-custom-500 flex flex-1 items-center justify-center gap-2">
|
||||
<Icon icon="tabler:check" className="h-5 w-5" />
|
||||
<p className="font-medium">Done</p>
|
||||
</div>
|
||||
<button className="absolute right-4 top-4 rounded-md p-2 text-neutral-500 hover:bg-neutral-700/30 hover:text-neutral-100">
|
||||
<Icon icon="tabler:dots-vertical" className="h-5 w-5" />
|
||||
</button>
|
||||
</Link>
|
||||
))}
|
||||
<div className="relative flex h-full flex-col items-center justify-center gap-4 rounded-lg border-2 border-dashed border-neutral-400 p-12">
|
||||
<Icon icon="tabler:plus" className="h-8 w-8 text-neutral-400" />
|
||||
<div className="text-xl font-semibold text-neutral-400">
|
||||
Create new set
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { type IIdeaBoxContainer } from '../../..'
|
||||
import HamburgerMenu from '../../../../../components/general/HamburgerMenu'
|
||||
import MenuItem from '../../../../../components/general/HamburgerMenu/MenuItem'
|
||||
|
||||
function ContainerGridItem({
|
||||
container,
|
||||
setCreateContainerModalOpen,
|
||||
setExistedData,
|
||||
setDeleteContainerConfirmationModalOpen
|
||||
}: {
|
||||
container: IIdeaBoxContainer
|
||||
setCreateContainerModalOpen: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: React.Dispatch<React.SetStateAction<IIdeaBoxContainer | null>>
|
||||
setDeleteContainerConfirmationModalOpen: React.Dispatch<
|
||||
React.SetStateAction<boolean>
|
||||
>
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-start gap-6 rounded-lg bg-neutral-50 p-8 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] hover:bg-neutral-100 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/70">
|
||||
<div
|
||||
className="rounded-lg p-4"
|
||||
style={{
|
||||
backgroundColor: container.color + '20'
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={container.icon}
|
||||
className="h-8 w-8"
|
||||
style={{
|
||||
color: container.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center text-2xl font-medium text-neutral-800 dark:text-neutral-100">
|
||||
{container.name}
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="tabler:article" className="h-5 w-5 text-neutral-500" />
|
||||
<span className="text-neutral-500">{container.text_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="tabler:link" className="h-5 w-5 text-neutral-500" />
|
||||
<span className="text-neutral-500">{container.link_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="tabler:photo" className="h-5 w-5 text-neutral-500" />
|
||||
<span className="text-neutral-500">{container.image_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={`/idea-box/${container.id}`}
|
||||
className="absolute left-0 top-0 h-full w-full"
|
||||
/>
|
||||
<HamburgerMenu position="absolute right-4 top-4 overscroll-contain">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setExistedData(container)
|
||||
setCreateContainerModalOpen('update')
|
||||
}}
|
||||
icon="tabler:edit"
|
||||
text="Edit"
|
||||
/>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setExistedData(container)
|
||||
setDeleteContainerConfirmationModalOpen(true)
|
||||
}}
|
||||
icon="tabler:trash"
|
||||
text="Delete"
|
||||
isRed
|
||||
/>
|
||||
</HamburgerMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContainerGridItem
|
||||
@@ -1,53 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import React from 'react'
|
||||
import { type IIdeaBoxContainer } from '../..'
|
||||
import ContainerGridItem from './components/ContainerGridItem'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
|
||||
function Container({
|
||||
filteredList,
|
||||
setCreateContainerModalOpen,
|
||||
setExistedData,
|
||||
setDeleteContainerConfirmationModalOpen
|
||||
}: {
|
||||
filteredList: IIdeaBoxContainer[]
|
||||
setCreateContainerModalOpen: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: React.Dispatch<React.SetStateAction<IIdeaBoxContainer | null>>
|
||||
setDeleteContainerConfirmationModalOpen: React.Dispatch<
|
||||
React.SetStateAction<boolean>
|
||||
>
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="mt-6 grid w-full grid-cols-[repeat(auto-fill,minmax(18rem,1fr))] gap-6 overflow-y-auto px-3 pb-12">
|
||||
{filteredList.map(container => (
|
||||
<ContainerGridItem
|
||||
key={container.id}
|
||||
container={container}
|
||||
setCreateContainerModalOpen={setCreateContainerModalOpen}
|
||||
setExistedData={setExistedData}
|
||||
setDeleteContainerConfirmationModalOpen={
|
||||
setDeleteContainerConfirmationModalOpen
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreateContainerModalOpen('create')
|
||||
}}
|
||||
className="relative flex h-full flex-col items-center justify-center gap-6 rounded-lg border-2 border-dashed border-neutral-400 p-8 hover:bg-neutral-200 dark:border-neutral-700 dark:hover:bg-neutral-800/20"
|
||||
>
|
||||
<Icon
|
||||
icon="tabler:cube-plus"
|
||||
className="h-8 w-8 text-neutral-400 dark:text-neutral-100"
|
||||
/>
|
||||
<div className="text-xl font-semibold text-neutral-400 dark:text-neutral-100">
|
||||
Create container
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Container
|
||||
@@ -1,90 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useState } from 'react'
|
||||
import Modal from '../../../../../components/general/Modal'
|
||||
import { type IIdeaBoxContainer } from '../../..'
|
||||
import { toast } from 'react-toastify'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
|
||||
function DeleteContainerConfirmationModal({
|
||||
isOpen,
|
||||
closeModal,
|
||||
containerDetails,
|
||||
updateContainerList
|
||||
}: {
|
||||
isOpen: boolean
|
||||
closeModal: () => void
|
||||
containerDetails: IIdeaBoxContainer | null
|
||||
updateContainerList: () => void
|
||||
}): React.ReactElement {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function deleteContainer(): void {
|
||||
if (containerDetails === null) return
|
||||
|
||||
setLoading(true)
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/idea-box/container/delete/${
|
||||
containerDetails.id
|
||||
}`,
|
||||
{
|
||||
method: 'DELETE'
|
||||
}
|
||||
)
|
||||
.then(async res => {
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.info("Uhh, hopefully you truly didn't need that container.")
|
||||
closeModal()
|
||||
updateContainerList()
|
||||
return data
|
||||
} else {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error("Oops! Couldn't delete the container. Please try again.")
|
||||
console.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen}>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Are you sure you want to delete {containerDetails?.name}?
|
||||
</h1>
|
||||
<p className="mt-2 text-neutral-500">
|
||||
This will delete the container and all the ideas inside it. This action
|
||||
is irreversible!
|
||||
</p>
|
||||
<div className="mt-8 flex w-full justify-around gap-2">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-neutral-800 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-neutral-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={deleteContainer}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-red-600"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="small-loader-light"></span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="tabler:trash" className="h-5 w-5" />
|
||||
DELETE
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteContainerConfirmationModal
|
||||
@@ -1,234 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Modal from '../../../../../components/general/Modal'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import ColorPickerModal from '../../../../../components/general/ColorPickerModal'
|
||||
import { toast } from 'react-toastify'
|
||||
import { type IIdeaBoxContainer } from '../../..'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import IconInput from '../../../../../components/general/IconSelector/IconInput'
|
||||
import Input from '../../../../../components/general/Input'
|
||||
|
||||
function ModifyContainerModal({
|
||||
openType,
|
||||
setOpenType,
|
||||
updateContainerList,
|
||||
existedData
|
||||
}: {
|
||||
openType: 'create' | 'update' | null
|
||||
setOpenType: React.Dispatch<React.SetStateAction<'create' | 'update' | null>>
|
||||
updateContainerList: () => void
|
||||
existedData: IIdeaBoxContainer | null
|
||||
}): React.ReactElement {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [containerName, setContainerName] = useState('')
|
||||
const [containerColor, setContainerColor] = useState('#FFFFFF')
|
||||
const [containerIcon, setContainerIcon] = useState('tabler:cube')
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false)
|
||||
const innerOpenType = useDebounce(openType, openType === null ? 300 : 0)
|
||||
|
||||
function updateContainerName(e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setContainerName(e.target.value)
|
||||
}
|
||||
|
||||
function updateContainerColor(e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setContainerColor(e.target.value)
|
||||
}
|
||||
|
||||
function updateContainerIcon(e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setContainerIcon(e.target.value)
|
||||
}
|
||||
|
||||
function onSubmitButtonClick(): void {
|
||||
if (
|
||||
containerName.trim().length === 0 ||
|
||||
containerColor.trim().length === 0 ||
|
||||
containerIcon.trim().length === 0
|
||||
) {
|
||||
toast.error('Please fill in all the fields.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const container = {
|
||||
name: containerName.trim(),
|
||||
color: containerColor.trim(),
|
||||
icon: containerIcon.trim()
|
||||
}
|
||||
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/idea-box/container/${innerOpenType}` +
|
||||
(innerOpenType === 'update' ? `/${existedData!.id}` : ''),
|
||||
{
|
||||
method: innerOpenType === 'create' ? 'PUT' : 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(container)
|
||||
}
|
||||
)
|
||||
.then(async res => {
|
||||
const data = await res.json()
|
||||
if (res.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
toast.success(
|
||||
{
|
||||
create: 'Yay! Container created. Time to fill it up.',
|
||||
update: 'Yay! Container updated.'
|
||||
}[innerOpenType!]
|
||||
)
|
||||
setOpenType(null)
|
||||
updateContainerList()
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error(
|
||||
{
|
||||
create: "Oops! Couldn't create the container. Please try again.",
|
||||
update: "Oops! Couldn't update the container. Please try again."
|
||||
}[innerOpenType!]
|
||||
)
|
||||
console.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (innerOpenType === 'update' && existedData !== null) {
|
||||
setContainerName(existedData.name)
|
||||
setContainerColor(existedData.color)
|
||||
setContainerIcon(existedData.icon)
|
||||
} else {
|
||||
setContainerName('')
|
||||
setContainerColor('#FFFFFF')
|
||||
setContainerIcon('tabler:cube')
|
||||
}
|
||||
}, [innerOpenType, existedData])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={openType !== null}>
|
||||
<div className="mb-8 flex items-center justify-between ">
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold">
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
create: 'tabler:plus',
|
||||
update: 'tabler:pencil'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
{
|
||||
{
|
||||
create: 'Create ',
|
||||
update: 'Update '
|
||||
}[innerOpenType!]
|
||||
}{' '}
|
||||
container
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpenType(null)
|
||||
}}
|
||||
className="rounded-md p-2 text-neutral-500 transition-all hover:bg-neutral-200/50 hover:text-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Icon icon="tabler:x" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
name="Container name"
|
||||
icon="tabler:cube"
|
||||
value={containerName}
|
||||
updateValue={updateContainerName}
|
||||
darker
|
||||
placeholder="My container"
|
||||
/>
|
||||
<div className="group relative mt-6 flex items-center gap-1 rounded-t-lg border-b-2 border-neutral-500 bg-neutral-200/50 focus-within:border-custom-500 dark:bg-neutral-800/50">
|
||||
<Icon
|
||||
icon="tabler:palette"
|
||||
className="ml-6 h-6 w-6 shrink-0 text-neutral-500 group-focus-within:text-custom-500"
|
||||
/>
|
||||
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span
|
||||
className={`pointer-events-none absolute left-[4.2rem] font-medium tracking-wide text-neutral-500 group-focus-within:text-custom-500 ${
|
||||
containerColor.length === 0
|
||||
? 'top-1/2 -translate-y-1/2 group-focus-within:top-6 group-focus-within:text-[14px]'
|
||||
: 'top-6 -translate-y-1/2 text-[14px]'
|
||||
}`}
|
||||
>
|
||||
Container color
|
||||
</span>
|
||||
<div className="mr-12 mt-6 flex w-full items-center gap-2 pl-4">
|
||||
<div
|
||||
className="mt-0.5 h-3 w-3 shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: containerColor
|
||||
}}
|
||||
></div>
|
||||
<input
|
||||
value={containerColor}
|
||||
onChange={updateContainerColor}
|
||||
placeholder="#FFFFFF"
|
||||
className="h-8 w-full rounded-lg bg-transparent p-6 pl-0 tracking-wide placeholder:text-transparent focus:outline-none focus:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setColorPickerOpen(true)
|
||||
}}
|
||||
className="mr-4 shrink-0 rounded-lg p-2 text-neutral-500 hover:bg-neutral-500/30 hover:text-neutral-200 focus:outline-none"
|
||||
>
|
||||
<Icon icon="tabler:color-picker" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<IconInput
|
||||
name="Container icon"
|
||||
icon={containerIcon}
|
||||
setIcon={setContainerIcon}
|
||||
/>
|
||||
<button
|
||||
disabled={loading}
|
||||
className="mt-8 flex h-16 items-center justify-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-800 transition-all hover:bg-custom-600"
|
||||
onClick={onSubmitButtonClick}
|
||||
>
|
||||
{!loading ? (
|
||||
<>
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
create: 'tabler:plus',
|
||||
update: 'tabler:pencil'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
{
|
||||
{
|
||||
create: 'CREATE',
|
||||
update: 'UPDATE'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<span className="small-loader-dark"></span>
|
||||
)}
|
||||
</button>
|
||||
</Modal>
|
||||
<ColorPickerModal
|
||||
isOpen={colorPickerOpen}
|
||||
setOpen={setColorPickerOpen}
|
||||
color={containerColor}
|
||||
setColor={setContainerColor}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModifyContainerModal
|
||||
@@ -1,101 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { type IIdeaBoxContainer } from '../../..'
|
||||
import { toast } from 'react-toastify'
|
||||
import GoBackButton from '../../../../../components/general/GoBackButton'
|
||||
|
||||
function ContainerHeader({ id }: { id: string }): React.ReactElement {
|
||||
const [containerDetails, setContainerDetails] = useState<
|
||||
IIdeaBoxContainer | 'loading' | 'error'
|
||||
>('loading')
|
||||
const navigate = useNavigate()
|
||||
|
||||
function fetchContainerDetails(): void {
|
||||
setContainerDetails('loading')
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/idea-box/container/get/${id}`)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
setContainerDetails(data.data)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setContainerDetails('error')
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchContainerDetails()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header className="flex flex-col gap-1 px-8 sm:px-12">
|
||||
<GoBackButton
|
||||
onClick={() => {
|
||||
navigate('/idea-box')
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1
|
||||
className={`flex items-center gap-4 ${
|
||||
typeof containerDetails !== 'string'
|
||||
? 'text-2xl sm:text-3xl'
|
||||
: 'text-2xl'
|
||||
} font-semibold `}
|
||||
>
|
||||
{(() => {
|
||||
switch (containerDetails) {
|
||||
case 'loading':
|
||||
return (
|
||||
<>
|
||||
<span className="small-loader-light"></span>
|
||||
Loading...
|
||||
</>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<>
|
||||
<Icon
|
||||
icon="tabler:alert-triangle"
|
||||
className="mt-0.5 h-7 w-7 text-red-500"
|
||||
/>
|
||||
Failed to fetch data from server.
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="rounded-lg p-3"
|
||||
style={{
|
||||
backgroundColor: containerDetails.color + '20'
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={containerDetails.icon}
|
||||
className="text-2xl sm:text-3xl"
|
||||
style={{
|
||||
color: containerDetails.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{containerDetails.name}
|
||||
</>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</h1>
|
||||
<button className="rounded-lg p-4 text-neutral-500 transition-all hover:bg-neutral-200/50 hover:text-neutral-800 dark:hover:bg-neutral-800 dark:hover:text-neutral-100">
|
||||
<Icon icon="tabler:dots-vertical" className="text-2xl" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContainerHeader
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
function CustomZoomContent({
|
||||
img
|
||||
}: {
|
||||
buttonUnzoom: React.ReactElement
|
||||
modalState: 'LOADING' | 'LOADED' | 'UNLOADING' | 'UNLOADED'
|
||||
img: any
|
||||
}): React.ReactElement {
|
||||
return <>{img}</>
|
||||
}
|
||||
|
||||
export default CustomZoomContent
|
||||
@@ -1,140 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
import { type IIdeaBoxEntry } from '..'
|
||||
import { toast } from 'react-toastify'
|
||||
import MenuItem from '../../../../../components/general/HamburgerMenu/MenuItem'
|
||||
|
||||
function EntryContextMenu({
|
||||
entry,
|
||||
setTypeOfModifyIdea,
|
||||
setModifyIdeaModalOpenType,
|
||||
setExistedData,
|
||||
setDeleteIdeaModalOpen,
|
||||
updateIdeaList
|
||||
}: {
|
||||
entry: IIdeaBoxEntry
|
||||
setTypeOfModifyIdea: React.Dispatch<
|
||||
React.SetStateAction<'link' | 'image' | 'text'>
|
||||
>
|
||||
setModifyIdeaModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: (data: any) => void
|
||||
setDeleteIdeaModalOpen: (state: boolean) => void
|
||||
updateIdeaList: () => void
|
||||
}): React.ReactElement {
|
||||
function pinIdea(ideaId: string): void {
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/idea-box/idea/pin/${ideaId}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
toast.info("Idea's position has been updated.")
|
||||
updateIdeaList()
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
function archiveIdea(ideaId: string): void {
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/idea-box/idea/archive/${ideaId}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
toast.info('Idea has been archived.')
|
||||
updateIdeaList()
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
as="div"
|
||||
className={`${
|
||||
entry.type === 'image' ? 'absolute right-2 top-2' : 'relative'
|
||||
} z-[999]`}
|
||||
>
|
||||
<Menu.Button>
|
||||
{({ open }) => (
|
||||
<div
|
||||
className={`shrink-0 rounded-lg bg-neutral-50 p-2 text-neutral-500 opacity-0 hover:bg-neutral-100 hover:text-neutral-800 group-hover:opacity-100 dark:bg-neutral-800/50 dark:text-neutral-100 dark:hover:bg-neutral-900 dark:hover:text-neutral-100 ${
|
||||
entry.type === 'image' &&
|
||||
'!shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)]'
|
||||
} ${
|
||||
open &&
|
||||
`${
|
||||
entry.type === 'image'
|
||||
? '!bg-neutral-200 dark:!bg-neutral-900'
|
||||
: '!bg-neutral-200 dark:!bg-neutral-800'
|
||||
} !opacity-100`
|
||||
}`}
|
||||
>
|
||||
<Icon icon="tabler:dots-vertical" className="text-xl" />
|
||||
</div>
|
||||
)}
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
className="absolute right-0 top-3"
|
||||
>
|
||||
<Menu.Items className="mt-8 w-48 overflow-hidden rounded-md bg-neutral-100 shadow-lg outline-none focus:outline-none dark:bg-neutral-800">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
pinIdea(entry.id)
|
||||
}}
|
||||
icon={entry.pinned ? 'tabler:pinned-off' : 'tabler:pin'}
|
||||
text={`${entry.pinned ? 'Unpin from' : 'Pin to'} top`}
|
||||
/>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
archiveIdea(entry.id)
|
||||
}}
|
||||
icon={entry.archived ? 'tabler:archive-off' : 'tabler:archive'}
|
||||
text={`${entry.archived ? 'Unarchive' : 'Archive'} idea`}
|
||||
/>
|
||||
{entry.type !== 'image' && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setTypeOfModifyIdea(entry.type)
|
||||
setExistedData(entry)
|
||||
setModifyIdeaModalOpenType('update')
|
||||
}}
|
||||
icon="tabler:pencil"
|
||||
text="Edit"
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setExistedData(entry)
|
||||
setDeleteIdeaModalOpen(true)
|
||||
}}
|
||||
icon="tabler:trash"
|
||||
text="Delete"
|
||||
isRed
|
||||
/>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryContextMenu
|
||||
@@ -1,100 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
|
||||
function FAB({
|
||||
setTypeOfModifyIdea,
|
||||
setModifyIdeaModalOpenType
|
||||
}: {
|
||||
setTypeOfModifyIdea: React.Dispatch<
|
||||
React.SetStateAction<'link' | 'image' | 'text'>
|
||||
>
|
||||
setModifyIdeaModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
as="div"
|
||||
className="group fixed bottom-6 right-6 z-[9998] overscroll-contain sm:bottom-12 sm:right-12 "
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Menu.Button className="relative z-10 flex items-center gap-2 rounded-lg bg-custom-500 p-4 font-semibold uppercase tracking-wider text-neutral-100 shadow-lg hover:bg-custom-600 dark:text-neutral-800">
|
||||
<Icon
|
||||
icon="tabler:plus"
|
||||
className={`h-6 w-6 shrink-0 transition-all ${
|
||||
open && 'rotate-45'
|
||||
}`}
|
||||
/>
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
enter="transition-all ease-out duration-300 overflow-hidden"
|
||||
enterFrom="max-h-0"
|
||||
enterTo="max-h-96"
|
||||
leave="transition-all ease-in duration-200 overflow-hidden"
|
||||
leaveFrom="max-h-96"
|
||||
leaveTo="max-h-0"
|
||||
className="absolute bottom-0 right-0 z-10 -translate-y-16 overflow-hidden"
|
||||
>
|
||||
<Menu.Items className="mt-2 rounded-lg shadow-lg outline-none focus:outline-none">
|
||||
<div className="py-1">
|
||||
{[
|
||||
['Text', 'tabler:text-size'],
|
||||
['Link', 'tabler:link'],
|
||||
['Image', 'tabler:photo']
|
||||
].map(([name, icon]) => (
|
||||
<Menu.Item key={name}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
setTypeOfModifyIdea(
|
||||
name.toLowerCase() as 'text' | 'image' | 'link'
|
||||
)
|
||||
setModifyIdeaModalOpenType('create')
|
||||
}}
|
||||
className={`group flex w-full items-center justify-end gap-4 rounded-md py-3 pr-2 ${
|
||||
active ? 'text-neutral-200' : 'text-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
<button
|
||||
className={`rounded-full ${
|
||||
active ? 'bg-neutral-300' : 'bg-neutral-200'
|
||||
} p-3`}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={`h-5 w-5 text-neutral-800 ${
|
||||
active && 'text-neutral-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
<div
|
||||
className={`fixed left-0 top-0 h-full w-full transition-transform ${
|
||||
open ? 'translate-x-0 duration-0' : 'translate-x-full delay-100'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-full w-full bg-neutral-900/50 backdrop-blur-sm transition-opacity ${
|
||||
open ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FAB
|
||||
@@ -1,57 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import React from 'react'
|
||||
import { type IIdeaBoxEntry } from '../..'
|
||||
import CustomZoomContent from '../CustomZoomContent'
|
||||
import EntryContextMenu from '../EntryContextMenu'
|
||||
import Zoom from 'react-medium-image-zoom'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
|
||||
function EntryImage({
|
||||
entry,
|
||||
setTypeOfModifyIdea,
|
||||
setModifyIdeaModalOpenType,
|
||||
setExistedData,
|
||||
setDeleteIdeaModalOpen,
|
||||
updateIdeaList
|
||||
}: {
|
||||
entry: IIdeaBoxEntry
|
||||
setTypeOfModifyIdea: React.Dispatch<
|
||||
React.SetStateAction<'link' | 'image' | 'text'>
|
||||
>
|
||||
setModifyIdeaModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: (data: any) => void
|
||||
setDeleteIdeaModalOpen: (state: boolean) => void
|
||||
updateIdeaList: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="group relative">
|
||||
{entry.pinned && (
|
||||
<Icon
|
||||
icon="tabler:pin"
|
||||
className="absolute -left-2 -top-2 z-[50] h-5 w-5 -rotate-90 text-red-500 drop-shadow-md"
|
||||
/>
|
||||
)}
|
||||
<Zoom zoomMargin={40} ZoomContent={CustomZoomContent}>
|
||||
<img
|
||||
src={`${import.meta.env.VITE_POCKETBASE_ENDPOINT}/api/files/${
|
||||
entry.collectionId
|
||||
}/${entry.id}/${entry.image}`}
|
||||
alt={''}
|
||||
className="my-4 rounded-lg shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)]"
|
||||
/>
|
||||
</Zoom>
|
||||
<EntryContextMenu
|
||||
entry={entry}
|
||||
setTypeOfModifyIdea={setTypeOfModifyIdea}
|
||||
setModifyIdeaModalOpenType={setModifyIdeaModalOpenType}
|
||||
setExistedData={setExistedData}
|
||||
setDeleteIdeaModalOpen={setDeleteIdeaModalOpen}
|
||||
updateIdeaList={updateIdeaList}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryImage
|
||||
@@ -1,57 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import React from 'react'
|
||||
import EntryContextMenu from '../EntryContextMenu'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import { type IIdeaBoxEntry } from '../..'
|
||||
|
||||
function EntryLink({
|
||||
entry,
|
||||
setTypeOfModifyIdea,
|
||||
setModifyIdeaModalOpenType,
|
||||
setExistedData,
|
||||
setDeleteIdeaModalOpen,
|
||||
updateIdeaList
|
||||
}: {
|
||||
entry: IIdeaBoxEntry
|
||||
setTypeOfModifyIdea: React.Dispatch<
|
||||
React.SetStateAction<'link' | 'image' | 'text'>
|
||||
>
|
||||
setModifyIdeaModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: (data: any) => void
|
||||
setDeleteIdeaModalOpen: (state: boolean) => void
|
||||
updateIdeaList: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="relative my-4 flex flex-col gap-2 rounded-lg bg-neutral-50 p-4 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
{entry.pinned && (
|
||||
<Icon
|
||||
icon="tabler:pin"
|
||||
className="absolute -left-2 -top-2 z-[50] h-5 w-5 -rotate-90 text-red-500 drop-shadow-md"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-xl font-semibold ">{entry.title}</h3>
|
||||
<EntryContextMenu
|
||||
entry={entry}
|
||||
setTypeOfModifyIdea={setTypeOfModifyIdea}
|
||||
setModifyIdeaModalOpenType={setModifyIdeaModalOpenType}
|
||||
setExistedData={setExistedData}
|
||||
setDeleteIdeaModalOpen={setDeleteIdeaModalOpen}
|
||||
updateIdeaList={updateIdeaList}
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={entry.content}
|
||||
className="text-custom-500 underline underline-offset-2"
|
||||
>
|
||||
{entry.content}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryLink
|
||||
@@ -1,49 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
import EntryContextMenu from '../EntryContextMenu'
|
||||
import { type IIdeaBoxEntry } from '../..'
|
||||
|
||||
function EntryText({
|
||||
entry,
|
||||
setTypeOfModifyIdea,
|
||||
setModifyIdeaModalOpenType,
|
||||
setExistedData,
|
||||
setDeleteIdeaModalOpen,
|
||||
updateIdeaList
|
||||
}: {
|
||||
entry: IIdeaBoxEntry
|
||||
setTypeOfModifyIdea: React.Dispatch<
|
||||
React.SetStateAction<'link' | 'image' | 'text'>
|
||||
>
|
||||
setModifyIdeaModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: (data: any) => void
|
||||
setDeleteIdeaModalOpen: (state: boolean) => void
|
||||
updateIdeaList: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="group relative my-4 flex items-start justify-between gap-2 rounded-lg bg-neutral-50 p-4 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
{entry.pinned && (
|
||||
<Icon
|
||||
icon="tabler:pin"
|
||||
className="absolute -left-2 -top-2 z-[50] h-5 w-5 -rotate-90 text-red-500 drop-shadow-md"
|
||||
/>
|
||||
)}
|
||||
<p className="mt-1.5 text-neutral-800 dark:text-neutral-100">
|
||||
{entry.content}
|
||||
</p>
|
||||
<EntryContextMenu
|
||||
entry={entry}
|
||||
setTypeOfModifyIdea={setTypeOfModifyIdea}
|
||||
setModifyIdeaModalOpenType={setModifyIdeaModalOpenType}
|
||||
setExistedData={setExistedData}
|
||||
setDeleteIdeaModalOpen={setDeleteIdeaModalOpen}
|
||||
updateIdeaList={updateIdeaList}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryText
|
||||
@@ -1,191 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
// @ts-expect-error - no types available
|
||||
import Column from 'react-columns'
|
||||
import { toast } from 'react-toastify'
|
||||
import Loading from '../../../../components/general/Loading'
|
||||
import Error from '../../../../components/general/Error'
|
||||
import EmptyStateScreen from '../../../../components/general/EmptyStateScreen'
|
||||
import CreateIdeaModal from './modals/ModifyIdeaModal'
|
||||
import DeleteIdeaConfirmationModal from './modals/DeleteIdeaConfirmationModal'
|
||||
import EntryImage from './components/IdeaEntry/EntryImage'
|
||||
import EntryText from './components/IdeaEntry/EntryText'
|
||||
import EntryLink from './components/IdeaEntry/EntryLink'
|
||||
import ContainerHeader from './components/ContainerHeader'
|
||||
import FAB from './components/FAB'
|
||||
|
||||
export interface IIdeaBoxEntry {
|
||||
collectionId: string
|
||||
collectionName: string
|
||||
container: string
|
||||
content: string
|
||||
created: string
|
||||
id: string
|
||||
image: string
|
||||
title: string
|
||||
type: 'text' | 'image' | 'link'
|
||||
updated: string
|
||||
pinned: boolean
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
function Ideas(): React.JSX.Element {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
const [data, setData] = useState<IIdeaBoxEntry[] | 'error' | 'loading'>(
|
||||
'loading'
|
||||
)
|
||||
|
||||
const [modifyIdeaModalOpenType, setModifyIdeaModalOpenType] = useState<
|
||||
null | 'create' | 'update'
|
||||
>(null)
|
||||
const [typeOfModifyIdea, setTypeOfModifyIdea] = useState<
|
||||
'text' | 'image' | 'link'
|
||||
>('text')
|
||||
const [existedData, setExistedData] = useState<IIdeaBoxEntry | null>(null)
|
||||
const [deleteIdeaModalOpen, setDeleteIdeaModalOpen] = useState(false)
|
||||
|
||||
function updateIdeaList(): void {
|
||||
setData('loading')
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/idea-box/idea/list/${id}`)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
setData(data.data)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setData('error')
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateIdeaList()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="relative min-h-0 w-full min-w-0 flex-1 overflow-y-auto">
|
||||
<ContainerHeader id={id!} />
|
||||
{(() => {
|
||||
switch (data) {
|
||||
case 'loading':
|
||||
return <Loading />
|
||||
case 'error':
|
||||
return <Error message="Failed to fetch data from server." />
|
||||
default:
|
||||
return data.length > 0 ? (
|
||||
<Column
|
||||
queries={[
|
||||
{
|
||||
columns: 1,
|
||||
query: 'min-width: 0px'
|
||||
},
|
||||
{
|
||||
columns: 2,
|
||||
query: 'min-width: 768px'
|
||||
},
|
||||
{
|
||||
columns: 3,
|
||||
query: 'min-width: 1024px'
|
||||
},
|
||||
{
|
||||
columns: 4,
|
||||
query: 'min-width: 1280px'
|
||||
},
|
||||
{
|
||||
columns: 5,
|
||||
query: 'min-width: 1536px'
|
||||
}
|
||||
]}
|
||||
gap="0.5rem"
|
||||
className="mt-8 h-max px-8 sm:px-12"
|
||||
>
|
||||
{data.map(entry => {
|
||||
switch (entry.type) {
|
||||
case 'image':
|
||||
return (
|
||||
<EntryImage
|
||||
entry={entry}
|
||||
setTypeOfModifyIdea={setTypeOfModifyIdea}
|
||||
setModifyIdeaModalOpenType={
|
||||
setModifyIdeaModalOpenType
|
||||
}
|
||||
setExistedData={setExistedData}
|
||||
setDeleteIdeaModalOpen={setDeleteIdeaModalOpen}
|
||||
updateIdeaList={updateIdeaList}
|
||||
/>
|
||||
)
|
||||
case 'text':
|
||||
return (
|
||||
<EntryText
|
||||
entry={entry}
|
||||
setTypeOfModifyIdea={setTypeOfModifyIdea}
|
||||
setModifyIdeaModalOpenType={
|
||||
setModifyIdeaModalOpenType
|
||||
}
|
||||
setExistedData={setExistedData}
|
||||
setDeleteIdeaModalOpen={setDeleteIdeaModalOpen}
|
||||
updateIdeaList={updateIdeaList}
|
||||
/>
|
||||
)
|
||||
case 'link':
|
||||
return (
|
||||
<EntryLink
|
||||
entry={entry}
|
||||
setTypeOfModifyIdea={setTypeOfModifyIdea}
|
||||
setModifyIdeaModalOpenType={
|
||||
setModifyIdeaModalOpenType
|
||||
}
|
||||
setExistedData={setExistedData}
|
||||
setDeleteIdeaModalOpen={setDeleteIdeaModalOpen}
|
||||
updateIdeaList={updateIdeaList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <></>
|
||||
})}
|
||||
</Column>
|
||||
) : (
|
||||
<EmptyStateScreen
|
||||
setModifyModalOpenType={setModifyIdeaModalOpenType}
|
||||
title="No ideas yet"
|
||||
description="Hmm... Seems a bit empty here. Consider adding some innovative ideas."
|
||||
icon="tabler:bulb-off"
|
||||
ctaContent="new idea"
|
||||
/>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
<FAB
|
||||
setModifyIdeaModalOpenType={setModifyIdeaModalOpenType}
|
||||
setTypeOfModifyIdea={setTypeOfModifyIdea}
|
||||
/>
|
||||
</section>
|
||||
<CreateIdeaModal
|
||||
openType={modifyIdeaModalOpenType}
|
||||
typeOfModifyIdea={typeOfModifyIdea}
|
||||
setOpenType={setModifyIdeaModalOpenType}
|
||||
containerId={id as string}
|
||||
updateIdeaList={updateIdeaList}
|
||||
existedData={existedData}
|
||||
/>
|
||||
<DeleteIdeaConfirmationModal
|
||||
isOpen={deleteIdeaModalOpen}
|
||||
closeModal={() => {
|
||||
setDeleteIdeaModalOpen(false)
|
||||
}}
|
||||
ideaDetails={existedData}
|
||||
setData={setData}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Ideas
|
||||
@@ -1,93 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useState } from 'react'
|
||||
import Modal from '../../../../../components/general/Modal'
|
||||
import { toast } from 'react-toastify'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import { type IIdeaBoxEntry } from '..'
|
||||
|
||||
function DeleteIdeaConfirmationModal({
|
||||
isOpen,
|
||||
setData,
|
||||
closeModal,
|
||||
ideaDetails
|
||||
}: {
|
||||
isOpen: boolean
|
||||
setData: React.Dispatch<
|
||||
React.SetStateAction<IIdeaBoxEntry[] | 'error' | 'loading'>
|
||||
>
|
||||
closeModal: () => void
|
||||
ideaDetails: IIdeaBoxEntry | null
|
||||
}): React.ReactElement {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function deleteIdea(): void {
|
||||
if (ideaDetails === null) return
|
||||
|
||||
setLoading(true)
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/idea-box/idea/delete/${ideaDetails.id}`,
|
||||
{
|
||||
method: 'DELETE'
|
||||
}
|
||||
)
|
||||
.then(async res => {
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.info("Uhh, hopefully you truly didn't need that idea.")
|
||||
closeModal()
|
||||
setData(prev => {
|
||||
if (prev === 'error' || prev === 'loading') return prev
|
||||
return prev.filter(idea => idea.id !== ideaDetails?.id)
|
||||
})
|
||||
return data
|
||||
} else {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error("Oops! Couldn't delete the idea. Please try again.")
|
||||
console.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen}>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Are you sure you want to delete this {ideaDetails?.type} idea?
|
||||
</h1>
|
||||
<p className="mt-2 text-neutral-500">
|
||||
This idea will be gone forever. This action is irreversible!
|
||||
</p>
|
||||
<div className="mt-8 flex w-full justify-around gap-2">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-neutral-800 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-neutral-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={deleteIdea}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-red-600"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="small-loader-light"></span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="tabler:trash" className="h-5 w-5" />
|
||||
DELETE
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteIdeaConfirmationModal
|
||||
@@ -1,444 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import React, { useEffect, useState, useCallback, useContext } from 'react'
|
||||
import Modal from '../../../../../components/general/Modal'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { toast } from 'react-toastify'
|
||||
import { type IIdeaBoxEntry } from '..'
|
||||
import { PersonalizationContext } from '../../../../../providers/PersonalizationProvider'
|
||||
|
||||
function CreateIdeaModal({
|
||||
openType,
|
||||
setOpenType,
|
||||
typeOfModifyIdea,
|
||||
containerId,
|
||||
updateIdeaList,
|
||||
existedData
|
||||
}: {
|
||||
openType: 'create' | 'update' | null
|
||||
setOpenType: React.Dispatch<React.SetStateAction<'create' | 'update' | null>>
|
||||
typeOfModifyIdea: 'text' | 'image' | 'link'
|
||||
containerId: string
|
||||
updateIdeaList: () => void
|
||||
existedData: IIdeaBoxEntry | null
|
||||
}): React.ReactElement {
|
||||
const { theme } = useContext(PersonalizationContext)
|
||||
const innerOpenType = useDebounce(openType, openType === null ? 300 : 0)
|
||||
const [innerTypeOfModifyIdea, setInnerTypeOfModifyIdea] = useState<
|
||||
'text' | 'image' | 'link'
|
||||
>('text')
|
||||
const [ideaTitle, setIdeaTitle] = useState('')
|
||||
const [ideaContent, setIdeaContent] = useState('')
|
||||
const [ideaLink, setIdeaLink] = useState('')
|
||||
const [ideaImage, setIdeaImage] = useState<File | null>(null)
|
||||
const [preview, setPreview] = useState<string | ArrayBuffer | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const file = new FileReader()
|
||||
|
||||
file.onload = function () {
|
||||
setPreview(file.result)
|
||||
}
|
||||
|
||||
file.readAsDataURL(acceptedFiles[0])
|
||||
setIdeaImage(acceptedFiles[0])
|
||||
}, [])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop
|
||||
})
|
||||
|
||||
function updateIdeaTitle(event: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setIdeaTitle(event.target.value)
|
||||
}
|
||||
|
||||
function updateIdeaContent(
|
||||
event: React.FormEvent<HTMLTextAreaElement>
|
||||
): void {
|
||||
setIdeaContent(event.currentTarget.value)
|
||||
}
|
||||
|
||||
function updateIdeaLink(event: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setIdeaLink(event.target.value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setInnerTypeOfModifyIdea(typeOfModifyIdea)
|
||||
}, [typeOfModifyIdea])
|
||||
|
||||
useEffect(() => {
|
||||
if (openType === 'create') {
|
||||
setIdeaTitle('')
|
||||
setIdeaContent('')
|
||||
setIdeaLink('')
|
||||
setIdeaImage(null)
|
||||
setPreview(null)
|
||||
} else if (openType === 'update') {
|
||||
if (existedData !== null) {
|
||||
setIdeaTitle(existedData.title)
|
||||
setIdeaContent(existedData.content)
|
||||
setIdeaLink(existedData.content)
|
||||
setIdeaImage(null)
|
||||
setPreview(null)
|
||||
}
|
||||
}
|
||||
}, [openType, existedData])
|
||||
|
||||
function onSubmitButtonClick(): void {
|
||||
switch (innerTypeOfModifyIdea) {
|
||||
case 'text':
|
||||
if (ideaContent.trim().length === 0) {
|
||||
toast.error('Idea content cannot be empty.')
|
||||
return
|
||||
}
|
||||
break
|
||||
case 'image':
|
||||
if (ideaImage === null) {
|
||||
toast.error('Idea image cannot be empty.')
|
||||
return
|
||||
}
|
||||
break
|
||||
case 'link':
|
||||
if (ideaTitle.trim().length === 0 || ideaLink.trim().length === 0) {
|
||||
toast.error('Idea title and link cannot be empty.')
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('title', ideaTitle.trim())
|
||||
formData.append('content', ideaContent.trim())
|
||||
formData.append('link', ideaLink.trim())
|
||||
formData.append('image', ideaImage!)
|
||||
formData.append('type', innerTypeOfModifyIdea)
|
||||
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/idea-box/idea/${
|
||||
innerOpenType === 'create' ? 'create' : 'update'
|
||||
}/${innerOpenType === 'create' ? containerId : existedData!.id}`,
|
||||
{
|
||||
method: innerOpenType === 'create' ? 'PUT' : 'PATCH',
|
||||
...(innerOpenType === 'update' && {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}),
|
||||
body:
|
||||
innerOpenType === 'create'
|
||||
? formData
|
||||
: JSON.stringify({
|
||||
title: ideaTitle.trim(),
|
||||
content: ideaContent.trim(),
|
||||
link: ideaLink.trim(),
|
||||
type: innerTypeOfModifyIdea
|
||||
})
|
||||
}
|
||||
)
|
||||
.then(async res => {
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.success(
|
||||
`Yay! Idea ${
|
||||
innerOpenType === 'create' ? 'created' : 'updated'
|
||||
} successfully.`
|
||||
)
|
||||
updateIdeaList()
|
||||
setOpenType(null)
|
||||
return data
|
||||
} else {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error(
|
||||
`Oops! Couldn't ${
|
||||
innerOpenType === 'create' ? 'create' : 'update'
|
||||
} the idea. Please try again.`
|
||||
)
|
||||
console.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={openType !== null}>
|
||||
<div className="mb-8 flex w-[50vw] items-center justify-between">
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold">
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
create: 'tabler:plus',
|
||||
update: 'tabler:pencil'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
{
|
||||
{
|
||||
create: 'New ',
|
||||
update: 'Update '
|
||||
}[innerOpenType!]
|
||||
}{' '}
|
||||
{innerOpenType === 'create' ? (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<Menu.Button className="inline-flex w-full items-center justify-center rounded-md border-2 border-neutral-300 p-2 px-4 text-lg font-semibold tracking-wide text-neutral-800 shadow-sm outline-none hover:bg-neutral-200/50 focus:outline-none dark:border-neutral-800 dark:bg-neutral-800/50 dark:text-neutral-200">
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
text: 'tabler:article',
|
||||
image: 'tabler:photo',
|
||||
link: 'tabler:link'
|
||||
}[innerTypeOfModifyIdea]
|
||||
}
|
||||
className="mr-2 h-5 w-5"
|
||||
/>
|
||||
{innerTypeOfModifyIdea === 'text'
|
||||
? 'Text'
|
||||
: innerTypeOfModifyIdea === 'image'
|
||||
? 'Image'
|
||||
: 'Link'}
|
||||
<Icon
|
||||
icon="tabler:chevron-down"
|
||||
className="-mr-1 ml-2 h-4 w-4 stroke-[2px]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
className="absolute left-0 z-[999] mt-2"
|
||||
>
|
||||
<Menu.Items className="w-56 overflow-hidden rounded-lg bg-neutral-100 shadow-lg outline-none focus:outline-none dark:bg-neutral-800">
|
||||
{[
|
||||
['text', 'tabler:article', 'Text'],
|
||||
...[['image', 'tabler:photo', 'Image']],
|
||||
['link', 'tabler:link', 'Link']
|
||||
].map(([type, icon, name]) => (
|
||||
<Menu.Item key={type}>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
setInnerTypeOfModifyIdea(
|
||||
type as 'text' | 'image' | 'link'
|
||||
)
|
||||
}}
|
||||
className={`group flex w-full items-center rounded-md p-4 text-base ${
|
||||
type === innerTypeOfModifyIdea
|
||||
? 'text-neutral-800 dark:text-neutral-100'
|
||||
: active
|
||||
? 'bg-neutral-200/50 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100'
|
||||
: 'text-neutral-500 hover:bg-neutral-200/50 dark:text-neutral-400 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
className="mr-3 h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{name}
|
||||
{innerTypeOfModifyIdea === type && (
|
||||
<Icon
|
||||
icon="tabler:check"
|
||||
className="ml-auto h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
) : (
|
||||
innerTypeOfModifyIdea[0].toUpperCase() +
|
||||
innerTypeOfModifyIdea.slice(1) +
|
||||
' '
|
||||
)}
|
||||
Idea
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpenType(null)
|
||||
}}
|
||||
className="rounded-md p-2 text-neutral-500 transition-all hover:bg-neutral-200/50 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Icon icon="tabler:x" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
{innerTypeOfModifyIdea === 'link' && (
|
||||
<div className="group relative mb-6 flex items-center gap-1 rounded-t-lg border-b-2 border-neutral-500 bg-neutral-50 focus-within:border-custom-500 dark:bg-neutral-800/50">
|
||||
<Icon
|
||||
icon="tabler:bulb"
|
||||
className="ml-6 h-6 w-6 shrink-0 text-neutral-500 group-focus-within:text-custom-500"
|
||||
/>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span
|
||||
className={`pointer-events-none absolute left-[4.2rem] font-medium tracking-wide text-neutral-500 group-focus-within:text-custom-500 ${
|
||||
ideaTitle.length === 0
|
||||
? 'top-1/2 -translate-y-1/2 group-focus-within:top-6 group-focus-within:text-[14px]'
|
||||
: 'top-6 -translate-y-1/2 text-[14px]'
|
||||
}`}
|
||||
>
|
||||
Idea title
|
||||
</span>
|
||||
<input
|
||||
value={ideaTitle}
|
||||
onChange={updateIdeaTitle}
|
||||
placeholder="Mind blowing idea"
|
||||
className="mt-6 h-8 w-full rounded-lg bg-transparent p-6 pl-4 tracking-wide placeholder:text-transparent focus:outline-none focus:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{innerTypeOfModifyIdea !== 'image' ? (
|
||||
<div
|
||||
onFocus={e => {
|
||||
e.currentTarget.querySelector('textarea input')?.focus()
|
||||
}}
|
||||
className="group relative flex items-center gap-1 rounded-t-lg border-b-2 border-neutral-500 bg-neutral-50 focus-within:border-custom-500 dark:bg-neutral-800/50"
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
innerTypeOfModifyIdea === 'text'
|
||||
? 'tabler:file-text'
|
||||
: 'tabler:link'
|
||||
}
|
||||
className="ml-6 h-6 w-6 shrink-0 text-neutral-500 group-focus-within:text-custom-500"
|
||||
/>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span
|
||||
className={`pointer-events-none absolute left-[4.2rem] font-medium tracking-wide text-neutral-500 group-focus-within:text-custom-500 ${
|
||||
{
|
||||
text: ideaContent,
|
||||
link: ideaLink
|
||||
}[innerTypeOfModifyIdea].length === 0
|
||||
? 'top-1/2 -translate-y-1/2 group-focus-within:top-6 group-focus-within:text-[14px]'
|
||||
: 'top-6 -translate-y-1/2 text-[14px]'
|
||||
}`}
|
||||
>
|
||||
{innerTypeOfModifyIdea === 'text'
|
||||
? 'Idea content'
|
||||
: 'Link to idea'}
|
||||
</span>
|
||||
{innerTypeOfModifyIdea === 'text' ? (
|
||||
<textarea
|
||||
value={ideaContent}
|
||||
onInput={e => {
|
||||
e.currentTarget.style.height = 'auto'
|
||||
e.currentTarget.style.height =
|
||||
e.currentTarget.scrollHeight + 'px'
|
||||
updateIdeaContent(e)
|
||||
}}
|
||||
placeholder="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, lorem euismod."
|
||||
className="mt-6 min-h-[2rem] w-full resize-none rounded-lg bg-transparent p-6 pl-4 tracking-wide outline-none placeholder:text-transparent focus:outline-none focus:placeholder:text-neutral-400"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={ideaLink}
|
||||
onChange={updateIdeaLink}
|
||||
placeholder="https://example.com"
|
||||
className="mt-6 h-8 w-full rounded-lg bg-transparent p-6 pl-4 tracking-wide placeholder:text-transparent focus:outline-none focus:placeholder:text-neutral-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative flex h-[30rem] min-h-[8rem] w-full items-center justify-center overflow-hidden rounded-lg bg-neutral-800">
|
||||
<img
|
||||
src={preview as string}
|
||||
alt="preview"
|
||||
className="h-full w-full object-scale-down"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPreview(null)
|
||||
}}
|
||||
className="absolute right-4 top-4 rounded-lg bg-neutral-800 p-2 text-neutral-500 transition-all hover:bg-neutral-900"
|
||||
>
|
||||
<Icon icon="tabler:x" className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-full flex-col items-center justify-center rounded-lg border-[3px] border-dashed border-neutral-500 py-12"
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Icon
|
||||
icon="tabler:drag-drop"
|
||||
className="h-20 w-20 text-neutral-500"
|
||||
/>
|
||||
<div className="mt-4 text-center text-2xl font-medium text-neutral-500">
|
||||
{isDragActive ? "Drop it like it's hot" : 'Drag and drop to upload'}
|
||||
</div>
|
||||
<div className="mt-4 text-center text-lg font-semibold uppercase tracking-widest text-neutral-400">
|
||||
or
|
||||
</div>
|
||||
<label
|
||||
htmlFor="idea-image"
|
||||
className="mt-4 flex items-center gap-2 rounded-lg bg-neutral-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-neutral-600 dark:bg-neutral-100 dark:text-neutral-800 dark:hover:bg-neutral-200"
|
||||
>
|
||||
<Icon icon="tabler:upload" className="h-5 w-5" />
|
||||
Upload image
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={onSubmitButtonClick}
|
||||
className="mt-8 flex h-16 items-center justify-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-custom-600 dark:text-neutral-800"
|
||||
>
|
||||
{!loading ? (
|
||||
<>
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
create: 'tabler:plus',
|
||||
update: 'tabler:pencil'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
{
|
||||
{
|
||||
create: 'CREATE',
|
||||
update: 'UPDATE'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
(theme === 'system' &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches) ||
|
||||
theme === 'dark'
|
||||
? 'small-loader-dark'
|
||||
: 'small-loader-light'
|
||||
}
|
||||
></span>
|
||||
)}
|
||||
</button>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateIdeaModal
|
||||
@@ -1,151 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
import { Icon } from '@iconify/react'
|
||||
import { toast } from 'react-toastify'
|
||||
import Loading from '../../components/general/Loading'
|
||||
import Error from '../../components/general/Error'
|
||||
import ModifyContainerModal from './components/Container/modals/ModifyContainerModal'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import EmptyStateScreen from '../../components/general/EmptyStateScreen'
|
||||
import Container from './components/Container'
|
||||
import DeleteContainerConfirmationModal from './components/Container/modals/DeleteContainerConfirmationModal'
|
||||
|
||||
export interface IIdeaBoxContainer {
|
||||
collectionId: string
|
||||
collectionName: string
|
||||
color: string
|
||||
created: string
|
||||
icon: string
|
||||
id: string
|
||||
image_count: number
|
||||
link_count: number
|
||||
name: string
|
||||
text_count: number
|
||||
updated: string
|
||||
}
|
||||
|
||||
function IdeaBox(): React.JSX.Element {
|
||||
const [data, setData] = useState<IIdeaBoxContainer[] | 'error' | 'loading'>(
|
||||
'loading'
|
||||
)
|
||||
const [modifyContainerModalOpenType, setModifyContainerModalOpenType] =
|
||||
useState<'create' | 'update' | null>(null)
|
||||
const [
|
||||
deleteContainerConfirmationModalOpen,
|
||||
setDeleteContainerConfirmationModalOpen
|
||||
] = useState(false)
|
||||
const [existedData, setExistedData] = useState<IIdeaBoxContainer | null>(null)
|
||||
const [filteredList, setFilteredList] = useState<IIdeaBoxContainer[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
|
||||
function updateContainerList(): void {
|
||||
setData('loading')
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/idea-box/container/list`)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
setData(data.data)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setData('error')
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateContainerList()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (Array.isArray(data)) {
|
||||
if (debouncedSearchQuery.length === 0) {
|
||||
setFilteredList(data)
|
||||
} else {
|
||||
setFilteredList(
|
||||
data.filter(container =>
|
||||
container.name
|
||||
.toLowerCase()
|
||||
.includes(debouncedSearchQuery.toLowerCase())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [debouncedSearchQuery, data])
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-full flex-1 flex-col px-8 sm:px-12">
|
||||
<ModuleHeader
|
||||
title="Idea Box"
|
||||
desc="Sometimes you will randomly stumble upon a great idea."
|
||||
/>
|
||||
<div className="mt-8 flex min-h-0 w-full flex-1 flex-col">
|
||||
<search className="flex w-full items-center gap-4 rounded-lg bg-neutral-50 p-4 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<Icon icon="tabler:search" className="h-5 w-5 text-neutral-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => {
|
||||
setSearchQuery(e.target.value)
|
||||
}}
|
||||
placeholder="Search idea containers ..."
|
||||
className="w-full bg-transparent text-neutral-500 placeholder:text-neutral-400 focus:outline-none"
|
||||
/>
|
||||
</search>
|
||||
<>
|
||||
{(() => {
|
||||
switch (data) {
|
||||
case 'loading':
|
||||
return <Loading />
|
||||
case 'error':
|
||||
return <Error message="Failed to fetch data from server." />
|
||||
default:
|
||||
return data.length > 0 ? (
|
||||
<Container
|
||||
filteredList={filteredList}
|
||||
setCreateContainerModalOpen={
|
||||
setModifyContainerModalOpenType
|
||||
}
|
||||
setExistedData={setExistedData}
|
||||
setDeleteContainerConfirmationModalOpen={
|
||||
setDeleteContainerConfirmationModalOpen
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateScreen
|
||||
setModifyModalOpenType={setModifyContainerModalOpenType}
|
||||
title="No idea containers"
|
||||
description="Hmm... Seems a bit empty here. Consider creating one."
|
||||
icon="tabler:cube-off"
|
||||
ctaContent="Create container"
|
||||
/>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</>
|
||||
</div>
|
||||
<ModifyContainerModal
|
||||
openType={modifyContainerModalOpenType}
|
||||
setOpenType={setModifyContainerModalOpenType}
|
||||
updateContainerList={updateContainerList}
|
||||
existedData={existedData}
|
||||
/>
|
||||
<DeleteContainerConfirmationModal
|
||||
isOpen={deleteContainerConfirmationModalOpen}
|
||||
closeModal={() => {
|
||||
setExistedData(null)
|
||||
setDeleteContainerConfirmationModalOpen(false)
|
||||
}}
|
||||
containerDetails={existedData}
|
||||
updateContainerList={updateContainerList}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default IdeaBox
|
||||
@@ -1,387 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import GoBackButton from '../../../../../../components/general/GoBackButton'
|
||||
import MenuItem from '../../../../../../components/general/HamburgerMenu/MenuItem'
|
||||
import { type INotesEntry, type INotesPath } from '../../..'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
function DirectoryHeader({
|
||||
updateNotesEntries,
|
||||
setModifyFolderModalOpenType,
|
||||
setExistedData
|
||||
}: {
|
||||
updateNotesEntries: () => void
|
||||
setModifyFolderModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: React.Dispatch<React.SetStateAction<INotesEntry | null>>
|
||||
}): React.ReactElement {
|
||||
const [currentPath, setCurrentPath] = useState<
|
||||
| {
|
||||
icon: string
|
||||
path: INotesPath[]
|
||||
}
|
||||
| 'loading'
|
||||
| 'error'
|
||||
>('loading')
|
||||
const {
|
||||
workspace,
|
||||
subject,
|
||||
'*': path
|
||||
} = useParams<{
|
||||
workspace: string
|
||||
subject: string
|
||||
'*': string
|
||||
}>()
|
||||
const toastId = React.useRef<any>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
function fetchCurrentPath(): void {
|
||||
setCurrentPath('loading')
|
||||
fetch(
|
||||
`${
|
||||
import.meta.env.VITE_API_HOST
|
||||
}/notes/entry/path/${workspace}/${subject}/${path}`
|
||||
)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
|
||||
setCurrentPath(data.data)
|
||||
})
|
||||
.catch(() => {
|
||||
setCurrentPath('error')
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
function uploadFiles(): void {
|
||||
const fileInput = document.createElement('input')
|
||||
fileInput.type = 'file'
|
||||
fileInput.multiple = true
|
||||
fileInput.click()
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const files = fileInput.files
|
||||
|
||||
if (files === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('files', files[i], encodeURIComponent(files[i].name))
|
||||
}
|
||||
|
||||
formData.append(
|
||||
'parent',
|
||||
path !== undefined ? path.split('/').pop()! : ''
|
||||
)
|
||||
|
||||
fetch(
|
||||
`${
|
||||
import.meta.env.VITE_API_HOST
|
||||
}/notes/entry/upload/${workspace}/${subject}/${path}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
}
|
||||
)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
|
||||
toast.success('Yay! Files uploaded.')
|
||||
|
||||
updateNotesEntries()
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error('Failed to upload files. Error: ' + err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function uploadFolders(): void {
|
||||
const fileInput = document.createElement('input')
|
||||
fileInput.type = 'file'
|
||||
fileInput.multiple = true
|
||||
fileInput.directory = true
|
||||
fileInput.webkitdirectory = true
|
||||
fileInput.click()
|
||||
|
||||
fileInput.addEventListener('change', async () => {
|
||||
const files = fileInput.files
|
||||
|
||||
let uploaded = 0
|
||||
|
||||
if (files === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const filesChunk = []
|
||||
|
||||
for (let i = 0; i < files.length; i += 10) {
|
||||
filesChunk.push(Array.from(files).slice(i, i + 10))
|
||||
}
|
||||
|
||||
for (const chunk of filesChunk) {
|
||||
const formData = new FormData()
|
||||
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
formData.append(
|
||||
'files',
|
||||
chunk[i],
|
||||
encodeURIComponent(chunk[i].webkitRelativePath)
|
||||
)
|
||||
}
|
||||
|
||||
formData.append(
|
||||
'parent',
|
||||
path !== undefined ? path.split('/').pop()! : ''
|
||||
)
|
||||
|
||||
await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_API_HOST
|
||||
}/notes/entry/upload/${workspace}/${subject}/${path}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}
|
||||
)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error('Failed to upload folders. Error: ' + err)
|
||||
})
|
||||
|
||||
uploaded += chunk.length
|
||||
|
||||
const progress = uploaded / files.length
|
||||
|
||||
// check if we already displayed a toast
|
||||
if (toastId.current === undefined) {
|
||||
toastId.current = toast(
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon icon="tabler:upload" className="h-5 w-5" />
|
||||
<span>Uploading folders...</span>
|
||||
</span>,
|
||||
{ progress }
|
||||
)
|
||||
} else {
|
||||
toast.update(toastId.current, { progress })
|
||||
}
|
||||
}
|
||||
|
||||
updateNotesEntries()
|
||||
|
||||
toast.done(toastId.current)
|
||||
toast.dismiss(toastId.current)
|
||||
toastId.current = undefined
|
||||
toast.success('Yay! Folders uploaded.')
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCurrentPath()
|
||||
}, [path])
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoBackButton
|
||||
onClick={() => {
|
||||
navigate(
|
||||
`/notes/${(() => {
|
||||
if (path === undefined || path === '') {
|
||||
return subject !== undefined ? workspace : '/'
|
||||
}
|
||||
|
||||
const pathArray = path.split('/')
|
||||
pathArray.pop()
|
||||
return `${workspace}/${subject}/${pathArray.join('/')}`
|
||||
})()}`
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-[100] flex w-full items-center justify-between gap-4 sm:gap-12">
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 items-center gap-4 ${
|
||||
typeof currentPath !== 'string'
|
||||
? 'text-2xl sm:text-3xl'
|
||||
: 'text-2xl'
|
||||
} font-semibold`}
|
||||
>
|
||||
{(() => {
|
||||
switch (currentPath) {
|
||||
case 'loading':
|
||||
return (
|
||||
<>
|
||||
<span className="small-loader-light"></span>
|
||||
Loading...
|
||||
</>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<>
|
||||
<Icon
|
||||
icon="tabler:alert-triangle"
|
||||
className="mt-0.5 h-7 w-7 text-red-500"
|
||||
/>
|
||||
Failed to fetch data from server.
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<div className="relative rounded-lg p-3">
|
||||
<Icon
|
||||
icon={currentPath.icon}
|
||||
className="text-2xl text-custom-500 sm:text-3xl"
|
||||
/>
|
||||
<div className="absolute left-0 top-0 h-full w-full rounded-lg bg-custom-500 opacity-20" />
|
||||
</div>
|
||||
<div className="flex w-full min-w-0 flex-col gap-1">
|
||||
<div className="hidden items-center gap-1 text-sm text-neutral-500 md:flex">
|
||||
{currentPath.path.map((path, index) => (
|
||||
<>
|
||||
<Link
|
||||
to={`/notes/${currentPath.path
|
||||
.slice(0, index + 1)
|
||||
.map(path => path.id)
|
||||
.join('/')}`}
|
||||
key={index}
|
||||
className={`${
|
||||
index === currentPath.path.length - 1
|
||||
? 'text-custom-500'
|
||||
: ''
|
||||
} whitespace-nowrap`}
|
||||
>
|
||||
{path.name.slice(0, 20) +
|
||||
(path.name.length > 20 ? '...' : '')}
|
||||
</Link>
|
||||
{index !== currentPath.path.length - 1 && (
|
||||
<Icon
|
||||
icon="tabler:chevron-right"
|
||||
className="h-4 w-4 shrink-0 text-neutral-500"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<h1 className="w-full truncate">
|
||||
{currentPath.path[currentPath.path.length - 1].name}
|
||||
</h1>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="hidden rounded-lg p-4 text-neutral-500 transition-all hover:bg-neutral-200/50 hover:text-neutral-900 dark:hover:bg-neutral-700/50 dark:hover:text-neutral-100 md:block">
|
||||
<Icon icon="tabler:search" className="text-2xl" />
|
||||
</button>
|
||||
<button className="hidden rounded-lg p-4 text-neutral-500 transition-all hover:bg-neutral-200/50 hover:text-neutral-900 dark:hover:bg-neutral-700/50 dark:hover:text-neutral-100 md:block">
|
||||
<Icon icon="tabler:filter" className="text-2xl" />
|
||||
</button>
|
||||
<Menu as="div" className="relative z-50 hidden md:block">
|
||||
<Menu.Button className="flex items-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] transition-all hover:bg-custom-600 dark:text-neutral-800">
|
||||
<Icon icon="tabler:plus" className="text-xl" />
|
||||
new
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
className="absolute right-0 top-8"
|
||||
>
|
||||
<Menu.Items className="mt-8 w-48 overflow-hidden overscroll-contain rounded-md bg-neutral-100 shadow-lg outline-none focus:outline-none dark:bg-neutral-800">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setModifyFolderModalOpenType('create')
|
||||
setExistedData(null)
|
||||
}}
|
||||
icon="tabler:folder-plus"
|
||||
text="New Folder"
|
||||
/>
|
||||
<div className="w-full border-b border-neutral-300 dark:border-neutral-700" />
|
||||
<MenuItem
|
||||
onClick={uploadFiles}
|
||||
icon="ci:file-upload"
|
||||
text="File upload"
|
||||
/>
|
||||
<MenuItem
|
||||
onClick={uploadFolders}
|
||||
icon="ci:folder-upload"
|
||||
text="Folder upload"
|
||||
/>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
<button className="rounded-lg p-4 text-neutral-500 transition-all hover:bg-neutral-200/50 hover:text-neutral-800 dark:hover:bg-neutral-800 dark:hover:text-neutral-100">
|
||||
<Icon icon="tabler:dots-vertical" className="text-xl sm:text-2xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Menu as="div" className="absolute bottom-8 right-8 z-50 md:hidden">
|
||||
<Menu.Button className="flex items-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] transition-all hover:bg-custom-600 dark:text-neutral-800">
|
||||
<Icon icon="tabler:plus" className="text-xl" />
|
||||
new
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
className="absolute right-0 top-8"
|
||||
>
|
||||
<Menu.Items className="mt-8 w-48 overflow-hidden overscroll-contain rounded-md bg-neutral-100 shadow-lg outline-none focus:outline-none dark:bg-neutral-800">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setModifyFolderModalOpenType('create')
|
||||
setExistedData(null)
|
||||
}}
|
||||
icon="tabler:folder-plus"
|
||||
text="New Folder"
|
||||
/>
|
||||
<div className="w-full border-b border-neutral-300 dark:border-neutral-700" />
|
||||
<MenuItem
|
||||
onClick={uploadFiles}
|
||||
icon="ci:file-upload"
|
||||
text="File upload"
|
||||
/>
|
||||
<MenuItem
|
||||
onClick={uploadFolders}
|
||||
icon="ci:folder-upload"
|
||||
text="Folder upload"
|
||||
/>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DirectoryHeader
|
||||
@@ -1,24 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { type INotesEntry } from '../../../../..'
|
||||
|
||||
function EntryButton({ entry }: { entry: INotesEntry }): React.ReactElement {
|
||||
return entry.type === 'folder' ? (
|
||||
<Link
|
||||
to={`./${entry.id}`}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800/50"
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
href={`${import.meta.env.VITE_POCKETBASE_ENDPOINT}/api/files/${
|
||||
entry.collectionId
|
||||
}/${entry.id}/${entry.file}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="absolute left-0 top-0 h-full w-full rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800/50"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryButton
|
||||
@@ -1,26 +0,0 @@
|
||||
import moment from 'moment'
|
||||
import React from 'react'
|
||||
import { Tooltip } from 'react-tooltip'
|
||||
|
||||
function EntryCreationDate({
|
||||
id,
|
||||
date
|
||||
}: {
|
||||
id: string
|
||||
date: string
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="z-50 hidden w-1/5 shrink-0 items-center md:flex">
|
||||
<div
|
||||
data-tooltip-id={`date-tooltip-${id}`}
|
||||
data-tooltip-content={moment(date).format('MMMM Do YYYY, h:mm:ss a')}
|
||||
className="z-50 shrink-0 text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{moment(date).fromNow()}
|
||||
</div>
|
||||
<Tooltip id={`date-tooltip-${id}`} className="z-50" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryCreationDate
|
||||
@@ -1,43 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import React from 'react'
|
||||
import HamburgerMenu from '../../../../../../../../components/general/HamburgerMenu'
|
||||
import MenuItem from '../../../../../../../../components/general/HamburgerMenu/MenuItem'
|
||||
import { type INotesEntry } from '../../../../..'
|
||||
|
||||
function EntryMenu({
|
||||
entry,
|
||||
setModifyFolderModalOpenType,
|
||||
setExistedData,
|
||||
setDeleteFolderConfirmationModalOpen
|
||||
}: {
|
||||
entry: INotesEntry
|
||||
setModifyFolderModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: (data: any) => void
|
||||
setDeleteFolderConfirmationModalOpen: (state: boolean) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<HamburgerMenu position="relative">
|
||||
<MenuItem
|
||||
icon="tabler:edit"
|
||||
onClick={() => {
|
||||
setModifyFolderModalOpenType('update')
|
||||
setExistedData(entry)
|
||||
}}
|
||||
text="Rename"
|
||||
/>
|
||||
<MenuItem
|
||||
icon="tabler:trash"
|
||||
onClick={() => {
|
||||
setDeleteFolderConfirmationModalOpen(true)
|
||||
setExistedData(entry)
|
||||
}}
|
||||
text="Delete"
|
||||
isRed
|
||||
/>
|
||||
</HamburgerMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryMenu
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
function EntryName({ name }: { name: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="pointer-events-none z-50 w-[20rem] truncate text-lg font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryName
|
||||
@@ -1,60 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
import FILE_ICONS from '../../../../../../../constants/file_icons'
|
||||
import { type INotesEntry } from '../../../..'
|
||||
import EntryName from './components/EntryName'
|
||||
import EntryCreationDate from './components/EntryCreationDate'
|
||||
import EntryButton from './components/EntryButton'
|
||||
import EntryMenu from './components/EntryMenu'
|
||||
|
||||
function EntryItem({
|
||||
entry,
|
||||
setModifyFolderModalOpenType,
|
||||
setExistedData,
|
||||
setDeleteFolderConfirmationModalOpen
|
||||
}: {
|
||||
entry: INotesEntry
|
||||
setModifyFolderModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: (data: any) => void
|
||||
setDeleteFolderConfirmationModalOpen: (state: boolean) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="relative mt-0 flex min-w-0 items-center justify-between gap-4 p-6"
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
file:
|
||||
FILE_ICONS[
|
||||
entry.name.split('.').pop()! as keyof typeof FILE_ICONS
|
||||
] ?? 'tabler:file',
|
||||
folder: 'tabler:folder'
|
||||
}[entry.type]
|
||||
}
|
||||
className="pointer-events-auto z-50 h-7 w-7 shrink-0 text-neutral-500"
|
||||
/>
|
||||
<div className="flex w-full min-w-0 items-center justify-between gap-8">
|
||||
<EntryName name={entry.name} />
|
||||
<EntryCreationDate date={entry.created} id={entry.id} />
|
||||
<EntryButton entry={entry} />
|
||||
<EntryMenu
|
||||
entry={entry}
|
||||
setModifyFolderModalOpenType={setModifyFolderModalOpenType}
|
||||
setExistedData={setExistedData}
|
||||
setDeleteFolderConfirmationModalOpen={
|
||||
setDeleteFolderConfirmationModalOpen
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default EntryItem
|
||||
@@ -1,44 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import React from 'react'
|
||||
import { type INotesEntry } from '../..'
|
||||
import EntryItem from './components/EntryItem'
|
||||
|
||||
function Directory({
|
||||
notesEntries,
|
||||
setModifyFolderModalOpenType,
|
||||
setExistedData,
|
||||
setDeleteFolderConfirmationModalOpen
|
||||
}: {
|
||||
notesEntries: INotesEntry[]
|
||||
setModifyFolderModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: (data: any) => void
|
||||
setDeleteFolderConfirmationModalOpen: (state: boolean) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ul className="mt-6 flex h-full min-h-0 flex-col divide-y divide-neutral-300 overflow-y-auto dark:divide-neutral-700/50">
|
||||
{notesEntries
|
||||
.sort(
|
||||
(a, b) =>
|
||||
-a.type.localeCompare(b.type) || a.name.localeCompare(b.name)
|
||||
)
|
||||
.map(entry => (
|
||||
<EntryItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
setModifyFolderModalOpenType={setModifyFolderModalOpenType}
|
||||
setExistedData={setExistedData}
|
||||
setDeleteFolderConfirmationModalOpen={
|
||||
setDeleteFolderConfirmationModalOpen
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default Directory
|
||||
@@ -1,129 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable @typescript-eslint/member-delimiter-style */
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import Error from '../../../components/general/Error'
|
||||
import Loading from '../../../components/general/Loading'
|
||||
import EmptyStateScreen from '../../../components/general/EmptyStateScreen'
|
||||
import ModifyFolderModal from './modals/ModifyFolderModal'
|
||||
import DeleteFolderConfirmationModal from './modals/DeleteFolderConfirmationModal'
|
||||
import DirectoryHeader from './components/Directory/components/DirectoryHeader'
|
||||
import Directory from './components/Directory'
|
||||
|
||||
// Generated by https://quicktype.io
|
||||
|
||||
export interface INotesEntry {
|
||||
collectionId: string
|
||||
collectionName: string
|
||||
created: string
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
subject: string
|
||||
type: 'file' | 'folder'
|
||||
updated: string
|
||||
file: string
|
||||
}
|
||||
|
||||
export interface INotesPath {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function NotesSubject(): React.ReactElement {
|
||||
const { subject, '*': path } = useParams<{ subject: string; '*': string }>()
|
||||
|
||||
const [notesEntries, setNotesEntries] = useState<
|
||||
INotesEntry[] | 'loading' | 'error'
|
||||
>('loading')
|
||||
const [modifyFolderModalOpenType, setModifyFolderModalOpenType] = useState<
|
||||
'create' | 'update' | null
|
||||
>(null)
|
||||
const [
|
||||
deleteFolderConfirmationModalOpen,
|
||||
setDeleteFolderConfirmationModalOpen
|
||||
] = useState(false)
|
||||
const [existedData, setExistedData] = useState<INotesEntry | null>(null)
|
||||
|
||||
function updateNotesEntries(): void {
|
||||
setNotesEntries('loading')
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/notes/entry/list/${subject}/${path}`
|
||||
)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
setNotesEntries(data.data)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setNotesEntries('error')
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateNotesEntries()
|
||||
}, [path])
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-full flex-1 flex-col overflow-y-scroll px-8 md:px-12">
|
||||
<DirectoryHeader
|
||||
updateNotesEntries={updateNotesEntries}
|
||||
setModifyFolderModalOpenType={setModifyFolderModalOpenType}
|
||||
setExistedData={setExistedData}
|
||||
/>
|
||||
{(() => {
|
||||
switch (notesEntries) {
|
||||
case 'loading':
|
||||
return <Loading />
|
||||
case 'error':
|
||||
return <Error message="Failed to fetch data from server." />
|
||||
default:
|
||||
return notesEntries.length > 0 ? (
|
||||
<Directory
|
||||
notesEntries={notesEntries}
|
||||
setDeleteFolderConfirmationModalOpen={
|
||||
setDeleteFolderConfirmationModalOpen
|
||||
}
|
||||
setModifyFolderModalOpenType={setModifyFolderModalOpenType}
|
||||
setExistedData={setExistedData}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateScreen
|
||||
ctaContent="New Note"
|
||||
icon="tabler:file-off"
|
||||
title="Hmm... it seems a bit empty here."
|
||||
description="Time to upload some notes!"
|
||||
setModifyModalOpenType={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
<ModifyFolderModal
|
||||
openType={modifyFolderModalOpenType}
|
||||
setOpenType={setModifyFolderModalOpenType}
|
||||
existedData={existedData}
|
||||
updateNotesEntries={updateNotesEntries}
|
||||
/>
|
||||
<DeleteFolderConfirmationModal
|
||||
isOpen={deleteFolderConfirmationModalOpen}
|
||||
closeModal={() => {
|
||||
setExistedData(null)
|
||||
setDeleteFolderConfirmationModalOpen(false)
|
||||
}}
|
||||
folderDetails={existedData}
|
||||
updateNotesEntries={updateNotesEntries}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotesSubject
|
||||
@@ -1,88 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import Modal from '../../../../components/general/Modal'
|
||||
import { type INotesEntry } from '..'
|
||||
|
||||
function DeleteFolderConfirmationModal({
|
||||
isOpen,
|
||||
closeModal,
|
||||
folderDetails,
|
||||
updateNotesEntries
|
||||
}: {
|
||||
isOpen: boolean
|
||||
closeModal: () => void
|
||||
folderDetails: INotesEntry | null
|
||||
updateNotesEntries: () => void
|
||||
}): React.ReactElement {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function deleteFolder(): void {
|
||||
if (folderDetails === null) return
|
||||
|
||||
setLoading(true)
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/notes/entry/delete/${folderDetails.id}`,
|
||||
{
|
||||
method: 'DELETE'
|
||||
}
|
||||
)
|
||||
.then(async res => {
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.info("Uhh, hopefully you truly didn't need that folder.")
|
||||
closeModal()
|
||||
updateNotesEntries()
|
||||
return data
|
||||
} else {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error("Oops! Couldn't delete the folder. Please try again.")
|
||||
console.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen}>
|
||||
<h1 className="truncate text-2xl font-semibold">
|
||||
Are you sure you want to delete {folderDetails?.name}?
|
||||
</h1>
|
||||
<p className="mt-2 text-neutral-500">
|
||||
This will delete the folder and all the notes inside it. This action is
|
||||
irreversible!
|
||||
</p>
|
||||
<div className="mt-8 flex w-full justify-around gap-2">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-neutral-800 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-neutral-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={deleteFolder}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-red-600"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="small-loader-light"></span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="tabler:trash" className="h-5 w-5" />
|
||||
DELETE
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteFolderConfirmationModal
|
||||
@@ -1,178 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import { toast } from 'react-toastify'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import Modal from '../../../../components/general/Modal'
|
||||
import { useParams } from 'react-router'
|
||||
import { type INotesEntry } from '..'
|
||||
import Input from '../../../../components/general/Input'
|
||||
|
||||
function ModifyFolderModal({
|
||||
openType,
|
||||
setOpenType,
|
||||
updateNotesEntries,
|
||||
existedData
|
||||
}: {
|
||||
openType: 'create' | 'update' | null
|
||||
setOpenType: React.Dispatch<React.SetStateAction<'create' | 'update' | null>>
|
||||
updateNotesEntries: () => void
|
||||
existedData: INotesEntry | null
|
||||
}): React.ReactElement {
|
||||
const {
|
||||
workspace,
|
||||
subject,
|
||||
'*': path
|
||||
} = useParams<{
|
||||
workspace: string
|
||||
subject: string
|
||||
'*': string
|
||||
}>()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [folderName, setFolderName] = useState('')
|
||||
const innerOpenType = useDebounce(openType, openType === null ? 300 : 0)
|
||||
|
||||
function updateFolderName(e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setFolderName(e.target.value)
|
||||
}
|
||||
|
||||
function onSubmitButtonClick(): void {
|
||||
if (folderName.trim().length === 0) {
|
||||
toast.error('Please fill in all the fields.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const entry = {
|
||||
name: folderName.trim(),
|
||||
workspace,
|
||||
type: 'folder',
|
||||
parent: path !== undefined ? path.split('/').pop() : '',
|
||||
subject
|
||||
}
|
||||
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/notes/entry/${innerOpenType}/folder` +
|
||||
(innerOpenType === 'update' ? `/${existedData!.id}` : ''),
|
||||
{
|
||||
method: innerOpenType === 'create' ? 'PUT' : 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(entry)
|
||||
}
|
||||
)
|
||||
.then(async res => {
|
||||
const data = await res.json()
|
||||
if (res.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
toast.success(
|
||||
{
|
||||
create: 'Yay! Folder created. Time to fill it up.',
|
||||
update: 'Yay! Folder renamed.'
|
||||
}[innerOpenType!]
|
||||
)
|
||||
setOpenType(null)
|
||||
updateNotesEntries()
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error(
|
||||
{
|
||||
create: "Oops! Couldn't create the folder. Please try again.",
|
||||
update: "Oops! Couldn't rename the folder. Please try again."
|
||||
}[innerOpenType!] +
|
||||
' Error: ' +
|
||||
err
|
||||
)
|
||||
console.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (innerOpenType === 'update' && existedData !== null) {
|
||||
setFolderName(existedData.name)
|
||||
} else {
|
||||
setFolderName('')
|
||||
}
|
||||
}, [innerOpenType, existedData])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={openType !== null}>
|
||||
<div className="mb-8 flex items-center justify-between ">
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold">
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
create: 'tabler:plus',
|
||||
update: 'tabler:pencil'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
{
|
||||
{
|
||||
create: 'Create ',
|
||||
update: 'Rename '
|
||||
}[innerOpenType!]
|
||||
}{' '}
|
||||
folder
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpenType(null)
|
||||
}}
|
||||
className="rounded-md p-2 text-neutral-500 transition-all hover:bg-neutral-200/50 hover:text-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Icon icon="tabler:x" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
name="Folder Name"
|
||||
placeholder="My lovely folder"
|
||||
icon="tabler:folder"
|
||||
value={folderName}
|
||||
updateValue={updateFolderName}
|
||||
darker
|
||||
additionalClassName="w-[40vw]"
|
||||
/>
|
||||
<button
|
||||
disabled={loading}
|
||||
className="mt-8 flex h-16 items-center justify-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-custom-600 dark:text-neutral-800"
|
||||
onClick={onSubmitButtonClick}
|
||||
>
|
||||
{!loading ? (
|
||||
<>
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
create: 'tabler:plus',
|
||||
update: 'tabler:pencil'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
{
|
||||
{
|
||||
create: 'CREATE',
|
||||
update: 'RENAME'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<span className="small-loader-dark"></span>
|
||||
)}
|
||||
</button>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModifyFolderModal
|
||||
@@ -1,31 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
import { type INotesSubject } from './SubjectItem'
|
||||
|
||||
function CreateSubjectButton({
|
||||
setModifySubjectModalOpenType,
|
||||
setExistedData
|
||||
}: {
|
||||
setModifySubjectModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: React.Dispatch<React.SetStateAction<INotesSubject | null>>
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
setModifySubjectModalOpenType('create')
|
||||
setExistedData(null)
|
||||
}}
|
||||
className="relative flex h-full flex-col items-center justify-center gap-6 rounded-lg border-2 border-dashed border-neutral-400 p-8 hover:bg-neutral-200 dark:border-neutral-700 dark:hover:bg-neutral-800/20"
|
||||
>
|
||||
<Icon icon="tabler:folder-plus" className="h-16 w-16 text-neutral-500" />
|
||||
<div className="text-2xl font-medium tracking-wide text-neutral-500">
|
||||
Create subject
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateSubjectButton
|
||||
@@ -1,74 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import HamburgerMenu from '../../../../components/general/HamburgerMenu'
|
||||
import MenuItem from '../../../../components/general/HamburgerMenu/MenuItem'
|
||||
|
||||
export interface INotesSubject {
|
||||
workspace: string
|
||||
collectionId: string
|
||||
collectionName: string
|
||||
created: string
|
||||
description: string
|
||||
icon: string
|
||||
id: string
|
||||
title: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
function SubjectItem({
|
||||
subject,
|
||||
setModifySubjectModalOpenType,
|
||||
setExistedData,
|
||||
setDeleteSubjectConfirmationModalOpen
|
||||
}: {
|
||||
subject: INotesSubject
|
||||
setModifySubjectModalOpenType: React.Dispatch<
|
||||
React.SetStateAction<'create' | 'update' | null>
|
||||
>
|
||||
setExistedData: (data: any) => void
|
||||
setDeleteSubjectConfirmationModalOpen: (state: boolean) => void
|
||||
}): React.ReactElement {
|
||||
const { workspace } = useParams<{ workspace: string }>()
|
||||
|
||||
return (
|
||||
<div className="group relative flex h-full w-full flex-col items-center rounded-lg bg-neutral-50 p-8 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] hover:bg-neutral-100 dark:bg-neutral-800/50 dark:hover:bg-neutral-700/50">
|
||||
<Icon
|
||||
icon={subject.icon}
|
||||
className="pointer-events-none z-10 h-20 w-20 shrink-0 group-hover:text-custom-500"
|
||||
/>
|
||||
<h2 className="mt-8 text-center text-2xl font-medium uppercase tracking-widest">
|
||||
{subject.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-neutral-500">
|
||||
{subject.description}
|
||||
</p>
|
||||
<Link
|
||||
to={`/notes/${workspace}/${subject.id}`}
|
||||
className="absolute left-0 top-0 h-full w-full hover:bg-white/[0.05]"
|
||||
/>
|
||||
<HamburgerMenu position="absolute right-4 top-4 z-20">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setExistedData(subject)
|
||||
setModifySubjectModalOpenType('update')
|
||||
}}
|
||||
icon="tabler:edit"
|
||||
text="Edit"
|
||||
/>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setExistedData(subject)
|
||||
setDeleteSubjectConfirmationModalOpen(true)
|
||||
}}
|
||||
icon="tabler:trash"
|
||||
text="Delete"
|
||||
isRed
|
||||
/>
|
||||
</HamburgerMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubjectItem
|
||||
@@ -1,163 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ModuleHeader from '../../../components/general/ModuleHeader'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
import { type INotesWorkspace } from '..'
|
||||
import { toast } from 'react-toastify'
|
||||
import Error from '../../../components/general/Error'
|
||||
import Loading from '../../../components/general/Loading'
|
||||
import EmptyStateScreen from '../../../components/general/EmptyStateScreen'
|
||||
import ModifySubjectModal from './modals/ModifySubjectModal'
|
||||
import DeleteSubjectConfirmationModal from './modals/DeleteSubjectConfirmationModal'
|
||||
import GoBackButton from '../../../components/general/GoBackButton'
|
||||
import SubjectItem, { type INotesSubject } from './components/SubjectItem'
|
||||
import CreateSubjectButton from './components/CreateSubjectButton'
|
||||
|
||||
function NotesCategory(): React.ReactElement {
|
||||
const { workspace } = useParams<{ workspace: string }>()
|
||||
const [titleData, setTitleData] = useState<
|
||||
INotesWorkspace | 'error' | 'loading'
|
||||
>('loading')
|
||||
const [subjectsData, setSubjectsData] = useState<
|
||||
INotesSubject[] | 'error' | 'loading'
|
||||
>('loading')
|
||||
const [modifySubjectModalOpenType, setModifySubjectModalOpenType] = useState<
|
||||
'create' | 'update' | null
|
||||
>(null)
|
||||
const [
|
||||
deleteSubjectConfirmationModalOpen,
|
||||
setDeleteSubjectConfirmationModalOpen
|
||||
] = useState(false)
|
||||
const [existedData, setExistedData] = useState<INotesSubject | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
function updateTitleData(): void {
|
||||
setTitleData('loading')
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/notes/workspace/get/${workspace}`)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
setTitleData(data.data)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setTitleData('error')
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
function updateSubjectList(): void {
|
||||
setSubjectsData('loading')
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/notes/subject/list/${workspace}`)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
setSubjectsData(data.data)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setSubjectsData('error')
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateTitleData()
|
||||
updateSubjectList()
|
||||
}, [workspace])
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-full flex-1 flex-col overflow-y-scroll px-8 md:px-12">
|
||||
<GoBackButton
|
||||
onClick={() => {
|
||||
navigate('/notes')
|
||||
}}
|
||||
/>
|
||||
<ModuleHeader
|
||||
title={
|
||||
<>
|
||||
Notes -{' '}
|
||||
{titleData === 'loading' ? (
|
||||
<Icon icon="svg-spinners:180-ring" className="h-8 w-8" />
|
||||
) : titleData === 'error' ? (
|
||||
<span className="flex items-center gap-2 text-red-500">
|
||||
<Icon
|
||||
icon="tabler:alert-triangle"
|
||||
className="mt-1 h-8 w-8 stroke-red-500 stroke-[2px]"
|
||||
/>
|
||||
Failed to fetch data
|
||||
</span>
|
||||
) : (
|
||||
titleData.name
|
||||
)}
|
||||
</>
|
||||
}
|
||||
desc="A place to store all your involuntarily generated thoughts."
|
||||
/>
|
||||
<>
|
||||
{(() => {
|
||||
switch (subjectsData) {
|
||||
case 'loading':
|
||||
return <Loading />
|
||||
case 'error':
|
||||
return <Error message="Failed to fetch data from server." />
|
||||
default:
|
||||
return subjectsData.length > 0 ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] items-center justify-center gap-4 py-8">
|
||||
{subjectsData.map(subject => (
|
||||
<SubjectItem
|
||||
key={subject.id}
|
||||
subject={subject}
|
||||
setModifySubjectModalOpenType={
|
||||
setModifySubjectModalOpenType
|
||||
}
|
||||
setDeleteSubjectConfirmationModalOpen={
|
||||
setDeleteSubjectConfirmationModalOpen
|
||||
}
|
||||
setExistedData={setExistedData}
|
||||
/>
|
||||
))}
|
||||
<CreateSubjectButton
|
||||
setModifySubjectModalOpenType={
|
||||
setModifySubjectModalOpenType
|
||||
}
|
||||
setExistedData={setExistedData}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateScreen
|
||||
title="A bit empty here. "
|
||||
description="Create a new subject to start storing your notes."
|
||||
icon="tabler:folder-off"
|
||||
ctaContent="Create subject"
|
||||
setModifyModalOpenType={setModifySubjectModalOpenType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</>
|
||||
<DeleteSubjectConfirmationModal
|
||||
isOpen={deleteSubjectConfirmationModalOpen}
|
||||
closeModal={() => {
|
||||
setDeleteSubjectConfirmationModalOpen(false)
|
||||
}}
|
||||
subjectDetails={existedData}
|
||||
updateSubjectList={updateSubjectList}
|
||||
/>
|
||||
<ModifySubjectModal
|
||||
openType={modifySubjectModalOpenType}
|
||||
setOpenType={setModifySubjectModalOpenType}
|
||||
existedData={existedData}
|
||||
updateSubjectList={updateSubjectList}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotesCategory
|
||||
@@ -1,90 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import Modal from '../../../../components/general/Modal'
|
||||
import { type INotesSubject } from '../components/SubjectItem'
|
||||
|
||||
function DeleteSubjectConfirmationModal({
|
||||
isOpen,
|
||||
closeModal,
|
||||
subjectDetails,
|
||||
updateSubjectList
|
||||
}: {
|
||||
isOpen: boolean
|
||||
closeModal: () => void
|
||||
subjectDetails: INotesSubject | null
|
||||
updateSubjectList: () => void
|
||||
}): React.ReactElement {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function deleteFolder(): void {
|
||||
if (subjectDetails === null) return
|
||||
|
||||
setLoading(true)
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/notes/subject/delete/${
|
||||
subjectDetails.id
|
||||
}`,
|
||||
{
|
||||
method: 'DELETE'
|
||||
}
|
||||
)
|
||||
.then(async res => {
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.info("Uhh, hopefully you truly didn't need that folder.")
|
||||
closeModal()
|
||||
updateSubjectList()
|
||||
return data
|
||||
} else {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error("Oops! Couldn't delete the folder. Please try again.")
|
||||
console.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen}>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Are you sure you want to delete {subjectDetails?.title}?
|
||||
</h1>
|
||||
<p className="mt-2 text-neutral-500">
|
||||
This will delete the subject and all the notes inside it. This action is
|
||||
irreversible!
|
||||
</p>
|
||||
<div className="mt-8 flex w-full justify-around gap-2">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-neutral-800 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-neutral-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={deleteFolder}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-red-600"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="small-loader-light"></span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="tabler:trash" className="h-5 w-5" />
|
||||
DELETE
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteSubjectConfirmationModal
|
||||
@@ -1,200 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import { toast } from 'react-toastify'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import Modal from '../../../../components/general/Modal'
|
||||
import { useParams } from 'react-router'
|
||||
import Input from '../../../../components/general/Input'
|
||||
import IconInput from '../../../../components/general/IconSelector/IconInput'
|
||||
import { type INotesSubject } from '../components/SubjectItem'
|
||||
|
||||
function ModifySubjectModal({
|
||||
openType,
|
||||
setOpenType,
|
||||
updateSubjectList,
|
||||
existedData
|
||||
}: {
|
||||
openType: 'create' | 'update' | null
|
||||
setOpenType: React.Dispatch<React.SetStateAction<'create' | 'update' | null>>
|
||||
updateSubjectList: () => void
|
||||
existedData: INotesSubject | null
|
||||
}): React.ReactElement {
|
||||
const { workspace } = useParams<{ workspace: string }>()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [subjectName, setSubjectName] = useState('')
|
||||
const [subjectDescription, setSubjectDescription] = useState('')
|
||||
const [subjectIcon, setSubjectIcon] = useState('tabler:cube')
|
||||
const innerOpenType = useDebounce(openType, openType === null ? 300 : 0)
|
||||
|
||||
function updateSubjectName(e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
setSubjectName(e.target.value)
|
||||
}
|
||||
|
||||
function updateSubjectDescription(
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
): void {
|
||||
setSubjectDescription(e.target.value)
|
||||
}
|
||||
|
||||
function onSubmitButtonClick(): void {
|
||||
if (
|
||||
subjectName.trim().length === 0 ||
|
||||
subjectIcon.trim().length === 0 ||
|
||||
subjectDescription.trim().length === 0
|
||||
) {
|
||||
toast.error('Please fill in all the fields.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const subject = {
|
||||
title: subjectName.trim(),
|
||||
description: subjectDescription.trim(),
|
||||
icon: subjectIcon.trim(),
|
||||
workspace
|
||||
}
|
||||
|
||||
fetch(
|
||||
`${import.meta.env.VITE_API_HOST}/notes/subject/${innerOpenType}` +
|
||||
(innerOpenType === 'update' ? `/${existedData?.id}` : ''),
|
||||
{
|
||||
method: innerOpenType === 'create' ? 'PUT' : 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(subject)
|
||||
}
|
||||
)
|
||||
.then(async res => {
|
||||
const data = await res.json()
|
||||
if (res.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
toast.success(
|
||||
{
|
||||
create: 'Yay! Subject created. Time to fill it up.',
|
||||
update: 'Yay! Subject updated.'
|
||||
}[innerOpenType!]
|
||||
)
|
||||
setOpenType(null)
|
||||
updateSubjectList()
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error(
|
||||
{
|
||||
create: "Oops! Couldn't create the subject. Please try again.",
|
||||
update: "Oops! Couldn't update the subject. Please try again."
|
||||
}[innerOpenType!] +
|
||||
' Error: ' +
|
||||
err
|
||||
)
|
||||
console.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (innerOpenType === 'update' && existedData !== null) {
|
||||
setSubjectName(existedData.title)
|
||||
setSubjectDescription(existedData.description)
|
||||
setSubjectIcon(existedData.icon)
|
||||
} else {
|
||||
setSubjectName('')
|
||||
setSubjectDescription('')
|
||||
setSubjectIcon('tabler:cube')
|
||||
}
|
||||
}, [innerOpenType, existedData])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={openType !== null}>
|
||||
<div className="mb-8 flex items-center justify-between ">
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold">
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
create: 'tabler:plus',
|
||||
update: 'tabler:pencil'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
className="h-7 w-7"
|
||||
/>
|
||||
{
|
||||
{
|
||||
create: 'Create ',
|
||||
update: 'Update '
|
||||
}[innerOpenType!]
|
||||
}{' '}
|
||||
subject
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpenType(null)
|
||||
}}
|
||||
className="rounded-md p-2 text-neutral-500 transition-all hover:bg-neutral-200/50 hover:text-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Icon icon="tabler:x" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
name="Subject name"
|
||||
icon="tabler:book"
|
||||
value={subjectName}
|
||||
updateValue={updateSubjectName}
|
||||
darker
|
||||
additionalClassName="w-[40vw]"
|
||||
placeholder="My Subject"
|
||||
/>
|
||||
<Input
|
||||
name="Subject description"
|
||||
icon="tabler:file-text"
|
||||
value={subjectDescription}
|
||||
updateValue={updateSubjectDescription}
|
||||
darker
|
||||
additionalClassName="w-[40vw] mt-6"
|
||||
placeholder="The best subject in the world"
|
||||
/>
|
||||
<IconInput
|
||||
name="Subject icon"
|
||||
icon={subjectIcon}
|
||||
setIcon={setSubjectIcon}
|
||||
/>
|
||||
<button
|
||||
disabled={loading}
|
||||
className="mt-8 flex h-16 items-center justify-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 transition-all hover:bg-custom-600 dark:text-neutral-800"
|
||||
onClick={onSubmitButtonClick}
|
||||
>
|
||||
{!loading ? (
|
||||
<>
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
create: 'tabler:plus',
|
||||
update: 'tabler:pencil'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
{
|
||||
{
|
||||
create: 'CREATE',
|
||||
update: 'UPDATE'
|
||||
}[innerOpenType!]
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<span className="small-loader-dark"></span>
|
||||
)}
|
||||
</button>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModifySubjectModal
|
||||
@@ -1,82 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
import { toast } from 'react-toastify'
|
||||
import Loading from '../../components/general/Loading'
|
||||
import Error from '../../components/general/Error'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export interface INotesWorkspace {
|
||||
collectionId: string
|
||||
collectionName: string
|
||||
created: string
|
||||
icon: string
|
||||
id: string
|
||||
name: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
function Notes(): React.ReactElement {
|
||||
const [data, setData] = useState<INotesWorkspace[] | 'error' | 'loading'>(
|
||||
'loading'
|
||||
)
|
||||
|
||||
function updateNotesWorkspace(): void {
|
||||
setData('loading')
|
||||
fetch(`${import.meta.env.VITE_API_HOST}/notes/workspace/list`)
|
||||
.then(async response => {
|
||||
const data = await response.json()
|
||||
setData(data.data)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw data.message
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setData('error')
|
||||
toast.error('Failed to fetch data from server.')
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateNotesWorkspace()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-full flex-1 flex-col overflow-y-scroll px-12">
|
||||
<ModuleHeader
|
||||
title="Notes"
|
||||
desc="A place to store all your involuntarily generated thoughts."
|
||||
/>
|
||||
{(() => {
|
||||
if (data === 'loading') {
|
||||
return <Loading />
|
||||
} else if (data === 'error') {
|
||||
return <Error message="Failed to fetch data from server." />
|
||||
} else {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(20rem,1fr))] items-center justify-center gap-4 py-8">
|
||||
{data.map(workspace => (
|
||||
<Link
|
||||
to={`/notes/${workspace.id}`}
|
||||
key={workspace.id}
|
||||
className="group flex h-full w-full flex-col items-center rounded-lg bg-neutral-50 p-16 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] hover:bg-neutral-100 dark:bg-neutral-800/50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Icon
|
||||
icon={workspace.icon}
|
||||
className="h-20 w-20 shrink-0 group-hover:text-custom-500"
|
||||
/>
|
||||
<h2 className="mt-8 text-center text-2xl font-medium uppercase tracking-widest">
|
||||
{workspace.name}
|
||||
</h2>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Notes
|
||||
@@ -1,116 +0,0 @@
|
||||
import React, { Fragment, useContext } from 'react'
|
||||
import { PersonalizationContext } from '../../../providers/PersonalizationProvider'
|
||||
import { Listbox, Transition } from '@headlessui/react'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
|
||||
const COLORS = [
|
||||
'red',
|
||||
'pink',
|
||||
'purple',
|
||||
'deep-purple',
|
||||
'indigo',
|
||||
'blue',
|
||||
'light-blue',
|
||||
'cyan',
|
||||
'teal',
|
||||
'green',
|
||||
'light-green',
|
||||
'lime',
|
||||
'yellow',
|
||||
'amber',
|
||||
'orange',
|
||||
'deep-orange',
|
||||
'brown',
|
||||
'grey'
|
||||
]
|
||||
|
||||
function ThemeColorSelector(): React.ReactElement {
|
||||
const { themeColor, setThemeColor } = useContext(PersonalizationContext)
|
||||
|
||||
return (
|
||||
<div className="mb-12 mt-4 flex w-full items-center justify-between">
|
||||
<div>
|
||||
<h3 className="block text-xl font-medium leading-normal">
|
||||
Accent color
|
||||
</h3>
|
||||
<p className="text-neutral-500">
|
||||
Select or customize your UI accent color.
|
||||
</p>
|
||||
</div>
|
||||
<Listbox
|
||||
value={themeColor}
|
||||
onChange={color => {
|
||||
setThemeColor(color)
|
||||
}}
|
||||
>
|
||||
<div className="relative mt-1 w-48">
|
||||
<Listbox.Button className="relative flex w-full items-center gap-2 rounded-lg border-[1.5px] border-neutral-300/50 py-4 pl-4 pr-10 text-left focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 sm:text-sm">
|
||||
<span className="inline-block h-4 w-4 rounded-full bg-custom-500" />
|
||||
<span className="mt-[-1px] block truncate">
|
||||
{themeColor
|
||||
.split('-')
|
||||
.slice(1)
|
||||
.map(e => e[0].toUpperCase() + e.slice(1))
|
||||
.join(' ')}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<Icon
|
||||
icon="tabler:chevron-down"
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-in duration-100"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute mt-1 max-h-32 w-full divide-y divide-neutral-200 overflow-auto rounded-md bg-neutral-100 py-1 text-base shadow-lg focus:outline-none dark:divide-neutral-700 dark:bg-neutral-800/50 sm:text-sm">
|
||||
{COLORS.map((color, i) => (
|
||||
<Listbox.Option
|
||||
key={i}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none transition-all p-4 flex items-center justify-between ${
|
||||
active
|
||||
? 'bg-neutral-200/50 dark:bg-neutral-800'
|
||||
: '!bg-transparent'
|
||||
}`
|
||||
}
|
||||
value={`theme-${color}`}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<div>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={`theme-${color} inline-block h-4 w-4 rounded-full bg-custom-500`}
|
||||
/>
|
||||
{color
|
||||
.split('-')
|
||||
.map(e => e[0].toUpperCase() + e.slice(1))
|
||||
.join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
{selected && (
|
||||
<Icon
|
||||
icon="tabler:check"
|
||||
className="texy-lg block text-gray-400 group-hover:text-custom-200"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeColorSelector
|
||||
@@ -1,55 +0,0 @@
|
||||
import React, { useContext } from 'react'
|
||||
import { PersonalizationContext } from '../../../providers/PersonalizationProvider'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
|
||||
function ThemeSelector(): React.ReactElement {
|
||||
const { theme, setTheme } = useContext(PersonalizationContext)
|
||||
|
||||
return (
|
||||
<div className="mt-4 w-full">
|
||||
<h3 className="mt-6 block text-xl font-medium leading-normal">Theme</h3>
|
||||
<p className="text-neutral-500">Select or customize your UI theme.</p>
|
||||
<div className="mt-6 flex w-full flex-col gap-8 md:flex-row">
|
||||
{[
|
||||
{ name: 'System', Image: './mockup/system.png' },
|
||||
{ name: 'Light', Image: './mockup/light.png' },
|
||||
{ name: 'Dark', Image: './mockup/dark.png' }
|
||||
].map(({ name, Image }) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTheme(name.toLowerCase() as 'system' | 'light' | 'dark')
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<div
|
||||
className={`ring-2 ring-offset-8 ring-offset-neutral-50 dark:ring-offset-neutral-900 ${
|
||||
theme === name.toLowerCase()
|
||||
? 'ring-custom-500'
|
||||
: 'ring-neutral-200 dark:ring-neutral-700'
|
||||
} relative overflow-hidden rounded-lg lg:rounded-2xl`}
|
||||
>
|
||||
{theme === name.toLowerCase() && (
|
||||
<Icon
|
||||
icon="tabler:circle-check-filled"
|
||||
className="absolute bottom-2 right-2.5 block h-6 w-6 text-xl text-custom-500"
|
||||
/>
|
||||
)}
|
||||
<img src={Image} alt={name} className="w-full" />
|
||||
</div>
|
||||
<p
|
||||
className={`mt-4 ${
|
||||
theme === name.toLowerCase() && 'font-medium text-custom-500'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeSelector
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
import ThemeSelector from './components/ThemeSelector'
|
||||
import ThemeColorSelector from './components/ThemeColorSelector'
|
||||
|
||||
function Personalization(): React.ReactElement {
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-full flex-1 flex-col overflow-y-scroll px-8 sm:px-12">
|
||||
<ModuleHeader
|
||||
title="Personalisation"
|
||||
desc="Customise your experience with the app."
|
||||
/>
|
||||
<ThemeSelector />
|
||||
<div className="my-6 w-full border-b-[1.5px] border-neutral-200 dark:border-neutral-700" />
|
||||
<ThemeColorSelector />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Personalization
|
||||
@@ -1,70 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React, { useState } from 'react'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
|
||||
export default function Timer(): React.ReactElement {
|
||||
const [started, setStarted] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col items-center justify-center gap-12">
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
<div
|
||||
className="radial-progress absolute text-neutral-200 dark:text-neutral-800/50"
|
||||
style={{
|
||||
'--value': '100',
|
||||
'--size': '28rem',
|
||||
'--thickness': '20px'
|
||||
}}
|
||||
role="progressbar"
|
||||
></div>
|
||||
<div
|
||||
className="radial-progress flex items-center justify-center text-amber-500"
|
||||
style={{
|
||||
'--value': '70',
|
||||
'--size': '28rem',
|
||||
'--thickness': '20px'
|
||||
}}
|
||||
role="progressbar"
|
||||
>
|
||||
<div className="z-[9999] mt-12 flex flex-col items-center gap-4 text-neutral-800 dark:text-neutral-100">
|
||||
<span className="text-7xl font-medium tracking-widest">02:33</span>
|
||||
<span className="text-lg font-medium uppercase tracking-widest text-amber-500">
|
||||
short break
|
||||
</span>
|
||||
{started ? (
|
||||
<span className="text-lg font-medium tracking-widest text-neutral-100">
|
||||
#8
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStarted(true)
|
||||
}}
|
||||
className="rounded-lg p-4 text-neutral-800 hover:bg-neutral-50 dark:bg-neutral-800/50 dark:text-neutral-100"
|
||||
>
|
||||
<Icon icon="tabler:play" className="h-8 w-8 shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{started && (
|
||||
<div className="flex items-center gap-6">
|
||||
<button className="flex shrink-0 items-center gap-2 rounded-lg bg-amber-500 p-4 px-6 pr-7 font-semibold uppercase tracking-wider text-neutral-100 dark:text-neutral-800">
|
||||
<Icon icon="tabler:pause" className="h-5 w-5 shrink-0" />
|
||||
<span className="shrink-0">pause session</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStarted(false)
|
||||
}}
|
||||
className="flex shrink-0 items-center gap-2 rounded-lg bg-neutral-200 p-4 px-6 pr-7 font-semibold uppercase tracking-wider text-neutral-500 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700/50"
|
||||
>
|
||||
<Icon icon="tabler:square" className="h-5 w-5 shrink-0" />
|
||||
<span className="shrink-0">end session</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
import React from 'react'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import Timer from './components/Timer'
|
||||
|
||||
export default function PomodoroTimer(): React.JSX.Element {
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-full flex-1 flex-col px-12">
|
||||
<ModuleHeader
|
||||
title="Pomodoro Timer"
|
||||
desc="Increase your productivity by using the Pomodoro technique."
|
||||
/>
|
||||
<div className="mt-8 flex w-full flex-1">
|
||||
<Timer />
|
||||
<aside className="mb-12 w-2/6 overflow-y-scroll rounded-lg bg-neutral-50 p-6 dark:bg-neutral-800/50">
|
||||
<h1 className="mb-2 flex items-center gap-2 text-2xl font-semibold">
|
||||
<Icon icon="tabler:circle-check" className="text-3xl" />
|
||||
<span className="ml-2">Things to do</span>
|
||||
</h1>
|
||||
<ul className="mt-6 flex flex-col gap-4">
|
||||
<li className="flex items-center justify-between gap-4 rounded-lg border-l-4 border-indigo-500 bg-neutral-100 p-4 px-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold text-neutral-800">
|
||||
Buy groceries
|
||||
</div>
|
||||
<div className="text-sm text-rose-500">
|
||||
10:00 AM, 23 Nov 2023 (overdue 8 hours)
|
||||
</div>
|
||||
</div>
|
||||
<button className="h-6 w-6 rounded-full border-2 border-neutral-500 transition-all hover:border-orange-500" />
|
||||
</li>
|
||||
<li className="flex items-center justify-between gap-4 rounded-lg border-l-4 border-orange-500 bg-neutral-100 p-4 px-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold text-neutral-800">
|
||||
Do homework
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500">
|
||||
00:00 AM, 31 Jan 2024
|
||||
</div>
|
||||
</div>
|
||||
<button className="h-6 w-6 rounded-full border-2 border-neutral-500 transition-all hover:border-orange-500" />
|
||||
</li>
|
||||
<li className="flex items-center justify-between gap-4 rounded-lg border-l-4 border-orange-500 bg-neutral-100 p-4 px-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold text-neutral-800">
|
||||
Start doing revision for SPM Sejarah
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500">
|
||||
00:00 AM, 31 Jan 2024
|
||||
</div>
|
||||
</div>
|
||||
<button className="h-6 w-6 rounded-full border-2 border-neutral-500 transition-all hover:border-orange-500" />
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Icon } from '@iconify/react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
function Kanban(): React.JSX.Element {
|
||||
return (
|
||||
<section className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-y-auto pl-12">
|
||||
<div className="flex flex-col gap-1 pr-12">
|
||||
<Link
|
||||
to="/projects"
|
||||
className="mb-2 flex w-min items-center gap-2 rounded-lg p-2 pr-4 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Icon icon="tabler:chevron-left" className="text-xl" />
|
||||
<span className="whitespace-nowrap text-lg font-medium">Go back</span>
|
||||
</Link>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="flex items-center gap-4 text-3xl font-semibold dark:text-neutral-100">
|
||||
<div className="rounded-lg bg-custom-500/20 p-3 text-custom-500 dark:bg-neutral-800">
|
||||
<Icon icon="tabler:hammer" className="text-3xl" />
|
||||
</div>
|
||||
LifeForge.
|
||||
<div className="ml-2 rounded-full bg-yellow-500/20 px-4 py-1.5 text-xs font-medium uppercase tracking-widest text-yellow-500 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)]">
|
||||
In progress
|
||||
</div>
|
||||
</h1>
|
||||
<div className="flex gap-2 rounded-lg p-2">
|
||||
{[
|
||||
'tabler:layout-columns',
|
||||
'tabler:layout-list',
|
||||
'tabler:arrow-autofit-content'
|
||||
].map((icon, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`rounded-md p-4 ${
|
||||
index === 0
|
||||
? 'bg-neutral-300/50 dark:bg-neutral-700/50 dark:text-neutral-100'
|
||||
: 'text-neutral-400 hover:bg-neutral-200/50 dark:hover:bg-neutral-700/50'
|
||||
}`}
|
||||
>
|
||||
<Icon icon={icon} className="text-2xl" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-12 mt-8 flex min-h-0 min-w-0 flex-1 gap-4 overflow-x-auto overflow-y-hidden">
|
||||
{[
|
||||
['tabler:brain', 'Brainstorm', 'border-fuchsia-500'],
|
||||
['tabler:settings', 'In Progress', 'border-yellow-500'],
|
||||
['tabler:check', 'Done', 'border-green-500'],
|
||||
['tabler:bug', 'Bugs', 'border-red-500']
|
||||
].map(([icon, name, color], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex h-min max-h-full w-72 shrink-0 flex-col rounded-lg border-t-4 bg-neutral-50 p-6 pb-0 pr-4 dark:bg-neutral-800/50 ${color}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-4">
|
||||
<Icon icon={icon} className="text-2xl" />
|
||||
<span className="text-xl font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
{name}
|
||||
</span>
|
||||
</h3>
|
||||
<button className="rounded-lg p-2 hover:bg-neutral-700/50">
|
||||
<Icon icon="tabler:dots-vertical" className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-6 flex flex-col gap-2 overflow-y-auto pr-2">
|
||||
{Array(Math.floor(Math.random() * 10))
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center gap-4 rounded-lg bg-neutral-100 p-4 shadow-[2px_2px_3px_rgba(0,0,0,0.05)] hover:bg-neutral-700/50 dark:bg-neutral-700/30"
|
||||
>
|
||||
<span className="text-neutral-800 dark:text-neutral-100">
|
||||
{
|
||||
[
|
||||
'Lorem ipsum dolor sit amet',
|
||||
'consectetur adipiscing elit',
|
||||
'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua',
|
||||
'Ut enim ad minim veniam'
|
||||
][Math.floor(Math.random() * 4)]
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-center justify-center">
|
||||
<button className="mb-4 flex w-full items-center gap-2 rounded-lg border-neutral-500 p-4 pl-3 font-medium text-neutral-500 hover:bg-neutral-700/30">
|
||||
<Icon icon="tabler:plus" className="text-xl" />
|
||||
<span>Add a card</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Kanban
|
||||
@@ -1,297 +0,0 @@
|
||||
/* eslint-disable multiline-ternary */
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
import SidebarTitle from '../../components/Sidebar/components/SidebarTitle'
|
||||
import SidebarDivider from '../../components/Sidebar/components/SidebarDivider'
|
||||
import { Icon } from '@iconify/react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import SidebarItem from '../../components/Sidebar/components/SidebarItem'
|
||||
import { faker } from '@faker-js/faker'
|
||||
|
||||
function shuffle(array: any[]): any[] {
|
||||
let currentIndex = array.length
|
||||
let randomIndex
|
||||
|
||||
// While there remain elements to shuffle.
|
||||
while (currentIndex > 0) {
|
||||
// Pick a remaining element.
|
||||
randomIndex = Math.floor(Math.random() * currentIndex)
|
||||
currentIndex--
|
||||
|
||||
// And swap it with the current element.
|
||||
;[array[currentIndex], array[randomIndex]] = [
|
||||
array[randomIndex],
|
||||
array[currentIndex]
|
||||
]
|
||||
}
|
||||
|
||||
return array
|
||||
}
|
||||
|
||||
function Projects(): React.JSX.Element {
|
||||
const [icons, setIcons] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://api.iconify.design/collection?prefix=tabler')
|
||||
.then(async response => await response.json())
|
||||
.then(data => {
|
||||
setIcons(data.uncategorized)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-y-auto px-12">
|
||||
<ModuleHeader
|
||||
title="Projects"
|
||||
desc="It's time to stop procrastinating."
|
||||
/>
|
||||
<div className="mb-12 mt-8 flex min-h-0 w-full flex-1">
|
||||
<aside className="h-full w-1/4 overflow-hidden overflow-y-scroll rounded-lg bg-neutral-50 py-4 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<ul className="flex flex-col overflow-y-hidden hover:overflow-y-scroll">
|
||||
<SidebarItem icon="tabler:list" name="All Projects" />
|
||||
<SidebarItem icon="tabler:star-filled" name="Starred" />
|
||||
<SidebarDivider />
|
||||
<SidebarTitle name="category" />
|
||||
{[
|
||||
['tabler:world', 'Website'],
|
||||
['tabler:device-mobile', 'Mobile App'],
|
||||
['tabler:devices-pc', 'Desktop App']
|
||||
].map(([icon, name], index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="relative flex items-center gap-6 px-4 font-medium text-neutral-400 transition-all"
|
||||
>
|
||||
<div className="flex w-full items-center gap-6 whitespace-nowrap rounded-lg p-4 hover:bg-neutral-800">
|
||||
<Icon icon={icon} className="h-6 w-6 shrink-0" />
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{Math.floor(Math.random() * 10)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<SidebarDivider />
|
||||
<SidebarTitle name="status" />
|
||||
{[
|
||||
['tabler:zzz', 'Pending', 'bg-red-500'],
|
||||
['tabler:circle-check', 'In Progress', 'bg-yellow-500'],
|
||||
['tabler:circle-check', 'Completed', 'bg-green-500']
|
||||
].map(([icon, name, color], index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="relative flex items-center gap-6 px-4 font-medium text-neutral-400 transition-all"
|
||||
>
|
||||
<div className="flex w-full items-center gap-6 whitespace-nowrap rounded-lg p-4 hover:bg-neutral-200/50 dark:hover:bg-neutral-800">
|
||||
<span className={`block h-8 w-1.5 rounded-full ${color}`} />
|
||||
<Icon icon={icon} className="h-6 w-6 shrink-0" />
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{Math.floor(Math.random() * 10)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<SidebarDivider />
|
||||
<SidebarTitle name="visibility" />
|
||||
{[
|
||||
['tabler:brand-open-source', 'Open Source'],
|
||||
['tabler:briefcase', 'Private & Commercial']
|
||||
].map(([icon, name], index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="relative flex items-center gap-6 px-4 font-medium text-neutral-400 transition-all"
|
||||
>
|
||||
<div className="flex w-full items-center gap-6 whitespace-nowrap rounded-lg p-4 hover:bg-neutral-200/50 dark:hover:bg-neutral-800">
|
||||
<Icon icon={icon} className="h-6 w-6 shrink-0" />
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{Math.floor(Math.random() * 10)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<SidebarDivider />
|
||||
<SidebarTitle name="Technologies" />
|
||||
{[
|
||||
['simple-icons:react', 'React'],
|
||||
['simple-icons:angular', 'Angular'],
|
||||
['simple-icons:electron', 'Electron'],
|
||||
['simple-icons:python', 'Python'],
|
||||
['simple-icons:swift', 'Swift'],
|
||||
['simple-icons:android', 'Android'],
|
||||
['simple-icons:apple', 'iOS'],
|
||||
['simple-icons:windows', 'Windows'],
|
||||
['simple-icons:linux', 'Linux']
|
||||
].map(([icon, name], index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="relative flex items-center gap-6 px-4 font-medium text-neutral-400 transition-all"
|
||||
>
|
||||
<div className="flex w-full items-center gap-6 whitespace-nowrap rounded-lg p-4 hover:bg-neutral-200/50 dark:hover:bg-neutral-800">
|
||||
<Icon icon={icon} className="h-5 w-5 shrink-0" />
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{Math.floor(Math.random() * 10)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
<div className="ml-8 flex h-full flex-1 flex-col">
|
||||
<div className="mx-4 flex items-center justify-between">
|
||||
<h1 className="text-4xl font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
All Projects{' '}
|
||||
<span className="text-base text-neutral-400">(10)</span>
|
||||
</h1>
|
||||
<button className="flex shrink-0 items-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] hover:bg-custom-600 dark:text-neutral-800">
|
||||
<Icon icon="tabler:plus" className="h-5 w-5 shrink-0" />
|
||||
<span className="shrink-0">create</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-4 mt-6 flex items-center gap-4">
|
||||
<search className="flex w-full items-center gap-4 rounded-lg bg-neutral-50 p-4 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<Icon icon="tabler:search" className="h-5 w-5 text-neutral-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search projects ..."
|
||||
className="w-full bg-transparent text-neutral-500 placeholder:text-neutral-300 focus:outline-none"
|
||||
/>
|
||||
</search>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-1 flex-col overflow-y-auto">
|
||||
<ul className="flex flex-col">
|
||||
{Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="m-4 mt-0 flex items-center gap-4 rounded-lg bg-neutral-50 p-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50"
|
||||
>
|
||||
<Link
|
||||
to="./lifeforge"
|
||||
className="flex w-full items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex w-2/5 items-center gap-4">
|
||||
<div
|
||||
className={`h-10 w-1 shrink-0 rounded-full ${
|
||||
['bg-green-500', 'bg-yellow-500', 'bg-red-500'][
|
||||
Math.floor(Math.random() * 3)
|
||||
]
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`h-12 w-12 overflow-hidden rounded-lg p-2 ${
|
||||
[
|
||||
'bg-red-500/20 text-red-500',
|
||||
'bg-yellow-500/20 text-yellow-500',
|
||||
'bg-green-500/20 text-green-500',
|
||||
'bg-blue-500/20 text-blue-500',
|
||||
'bg-indigo-500/20 text-indigo-500',
|
||||
'bg-purple-500/20 text-purple-500',
|
||||
'bg-pink-500/20 text-pink-500',
|
||||
'bg-rose-500/20 text-rose-500',
|
||||
'bg-fuchsia-500/20 text-fuchsia-500',
|
||||
'bg-orange-500/20 text-orange-500',
|
||||
'bg-cyan-500/20 text-cyan-500',
|
||||
'bg-sky-500/20 text-sky-500',
|
||||
'bg-lime-500/20 text-lime-500',
|
||||
'bg-amber-500/20 text-amber-500',
|
||||
'bg-emerald-500/20 text-emerald-500',
|
||||
'bg-custom-500/20 text-custom-500'
|
||||
][Math.floor(Math.random() * 16)]
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
icon={`tabler:${
|
||||
icons[
|
||||
Math.floor(Math.random() * icons.length)
|
||||
] as string
|
||||
}`}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
{faker.commerce.productName()}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500">
|
||||
{
|
||||
['Website', 'Mobile App', 'Desktop App'][
|
||||
Math.floor(Math.random() * 3)
|
||||
]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{(() => {
|
||||
let randomLanguage = [
|
||||
['simple-icons:react', 'React', 'text-sky-500'],
|
||||
[
|
||||
'simple-icons:typescript',
|
||||
'TypeScript',
|
||||
'text-blue-500'
|
||||
],
|
||||
[
|
||||
'simple-icons:javascript',
|
||||
'JavaScript',
|
||||
'text-yellow-500'
|
||||
],
|
||||
['simple-icons:html5', 'HTML', 'text-orange-500'],
|
||||
['simple-icons:css3', 'CSS', 'text-blue-500'],
|
||||
[
|
||||
'simple-icons:tailwindcss',
|
||||
'Tailwind',
|
||||
'text-cyan-500'
|
||||
],
|
||||
[
|
||||
'simple-icons:nodedotjs',
|
||||
'Node.js',
|
||||
'text-green-500'
|
||||
],
|
||||
['simple-icons:express', 'Express', 'text-gray-500']
|
||||
]
|
||||
|
||||
randomLanguage = shuffle(randomLanguage)
|
||||
|
||||
return randomLanguage
|
||||
.slice(0, Math.floor(Math.random() * 5 + 2))
|
||||
.map(([name, bg, color], index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center gap-2 rounded-full ${bg} text-sm font-medium ${color}`}
|
||||
>
|
||||
<Icon icon={name} className="h-5 w-5" />
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 ">
|
||||
<Icon
|
||||
icon="tabler:chevron-right"
|
||||
className="h-5 w-5 stroke-[2px] text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default Projects
|
||||
@@ -1,128 +0,0 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import React from 'react'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
import SidebarDivider from '../../components/Sidebar/components/SidebarDivider'
|
||||
import SidebarItem from '../../components/Sidebar/components/SidebarItem'
|
||||
import SidebarTitle from '../../components/Sidebar/components/SidebarTitle'
|
||||
|
||||
function ReferenceBooks(): React.JSX.Element {
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-full flex-1 flex-col px-12">
|
||||
<ModuleHeader
|
||||
title="Reference Books"
|
||||
desc="A collection of reference books that accompany you on your learning journey."
|
||||
/>
|
||||
<div className="mb-12 mt-8 flex min-h-0 w-full flex-1">
|
||||
<aside className="h-full w-1/4 overflow-y-scroll rounded-lg bg-neutral-50 py-4 dark:bg-neutral-800/50">
|
||||
<ul className="flex flex-col overflow-y-hidden hover:overflow-y-scroll">
|
||||
<SidebarItem icon="tabler:list" name="All books" />
|
||||
<SidebarItem icon="tabler:star-filled" name="Starred" />
|
||||
<SidebarDivider />
|
||||
<SidebarTitle name="categories" />
|
||||
{[
|
||||
['tabler:math-integral-x', 'Calculus'],
|
||||
['tabler:math-pi', 'Mathematics'],
|
||||
['tabler:atom', 'Physics'],
|
||||
['tabler:code', 'Computer Science']
|
||||
].map(([icon, name], index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="relative flex items-center gap-6 px-4 font-medium text-neutral-400 transition-all"
|
||||
>
|
||||
<div className="flex w-full items-center gap-6 whitespace-nowrap rounded-lg p-4 hover:bg-neutral-800">
|
||||
<Icon icon={icon} className="h-5 w-5 shrink-0" />
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{Math.floor(Math.random() * 10)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<SidebarDivider />
|
||||
<SidebarTitle name="languages" />
|
||||
{[
|
||||
['emojione-monotone:flag-for-china', 'Chinese'],
|
||||
['emojione-monotone:flag-for-united-kingdom', 'English']
|
||||
].map(([icon, name], index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="relative flex items-center gap-6 px-4 font-medium text-neutral-400 transition-all"
|
||||
>
|
||||
<div className="flex w-full items-center gap-6 whitespace-nowrap rounded-lg p-4 hover:bg-neutral-800">
|
||||
<Icon icon={icon} className="h-5 w-5 shrink-0" />
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{Math.floor(Math.random() * 10)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
<div className="ml-12 flex h-full min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-4xl font-semibold text-neutral-100">
|
||||
All Books <span className="text-base text-neutral-400">(10)</span>
|
||||
</h1>
|
||||
<button className="flex shrink-0 items-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-800">
|
||||
<Icon icon="tabler:plus" className="h-5 w-5 shrink-0" />
|
||||
<span className="shrink-0">upload</span>
|
||||
</button>
|
||||
</div>
|
||||
<search className="mt-6 flex w-full items-center gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-800/50">
|
||||
<Icon icon="tabler:search" className="h-5 w-5 text-neutral-100" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search books ..."
|
||||
className="w-full bg-transparent text-neutral-100 placeholder:text-neutral-100 focus:outline-none"
|
||||
/>
|
||||
</search>
|
||||
<ul className="mt-6 grid min-h-0 grid-cols-3 gap-6 gap-y-12 overflow-y-auto">
|
||||
{Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="relative flex flex-col items-start rounded-lg"
|
||||
>
|
||||
<div className="flex h-72 w-full items-center justify-center rounded-lg bg-neutral-50 p-8 dark:bg-neutral-800/50">
|
||||
<img
|
||||
src={faker.image.imageUrl(300, 400, 'airport', true)}
|
||||
alt={faker.lorem.sentence()}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 text-xl font-medium text-neutral-100">
|
||||
{faker.commerce.productName()}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium text-neutral-400">
|
||||
{faker.person.fullName()}
|
||||
</div>
|
||||
<div className="mt-6 flex w-full flex-col gap-4">
|
||||
<button className="flex items-center justify-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-800">
|
||||
<Icon icon="tabler:book" className="h-5 w-5 shrink-0" />
|
||||
<span className="shrink-0">read</span>
|
||||
</button>
|
||||
<button className="flex items-center justify-center gap-2 rounded-lg bg-neutral-800 p-4 pr-5 font-semibold uppercase tracking-wider">
|
||||
<Icon
|
||||
icon="tabler:download"
|
||||
className="h-5 w-5 shrink-0"
|
||||
/>
|
||||
<span className="shrink-0">download</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReferenceBooks
|
||||
@@ -1,112 +0,0 @@
|
||||
import React from 'react'
|
||||
import ModuleHeader from '../../components/general/ModuleHeader'
|
||||
import { Icon } from '@iconify/react/dist/iconify.js'
|
||||
import SidebarItem from '../../components/Sidebar/components/SidebarItem'
|
||||
import SidebarDivider from '../../components/Sidebar/components/SidebarDivider'
|
||||
import SidebarTitle from '../../components/Sidebar/components/SidebarTitle'
|
||||
import { faker } from '@faker-js/faker'
|
||||
|
||||
export default function Resources(): React.JSX.Element {
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-full flex-1 flex-col px-12">
|
||||
<ModuleHeader
|
||||
title="Resources"
|
||||
desc="A collection of useful stuff for your coding journey."
|
||||
/>
|
||||
<div className="mb-12 mt-8 flex min-h-0 w-full flex-1">
|
||||
<aside className="h-full w-1/4 overflow-y-scroll rounded-lg bg-neutral-50 py-4 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<ul className="flex flex-col overflow-y-hidden hover:overflow-y-scroll">
|
||||
<SidebarItem icon="tabler:list" name="All Resources" />
|
||||
<SidebarItem icon="tabler:star-filled" name="Starred" />
|
||||
<SidebarDivider />
|
||||
<SidebarTitle name="categories" />
|
||||
{[
|
||||
['simple-icons:react', 'React Libraries'],
|
||||
['tabler:database', 'Databases'],
|
||||
['tabler:device-desktop-code', 'UI Frameworks'],
|
||||
['tabler:terminal', 'Command Line Tools'],
|
||||
['tabler:server', 'Servers']
|
||||
].map(([icon, name], index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="relative flex items-center gap-6 px-4 font-medium text-neutral-400 transition-all"
|
||||
>
|
||||
<div className="flex w-full items-center gap-6 whitespace-nowrap rounded-lg p-4 hover:bg-neutral-800">
|
||||
<Icon icon={icon} className="h-5 w-5 shrink-0" />
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{Math.floor(Math.random() * 10)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
<div className="ml-12 flex h-full min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-4xl font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
All Resources{' '}
|
||||
<span className="text-base text-neutral-400">(10)</span>
|
||||
</h1>
|
||||
<button className="flex shrink-0 items-center gap-2 rounded-lg bg-custom-500 p-4 pr-5 font-semibold uppercase tracking-wider text-neutral-100 dark:text-neutral-800">
|
||||
<Icon icon="tabler:plus" className="h-5 w-5 shrink-0" />
|
||||
<span className="shrink-0">add new</span>
|
||||
</button>
|
||||
</div>
|
||||
<search className="mt-6 flex w-full items-center gap-4 rounded-lg bg-neutral-50 p-4 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50">
|
||||
<Icon icon="tabler:search" className="h-5 w-5 text-neutral-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search resources ..."
|
||||
className="w-full bg-transparent text-neutral-500 placeholder:text-neutral-400 focus:outline-none"
|
||||
/>
|
||||
</search>
|
||||
<ul className="mt-6 flex min-h-0 flex-col gap-4 overflow-y-auto">
|
||||
{Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="relative flex items-center justify-between gap-4 rounded-lg bg-neutral-50 p-6 shadow-[4px_4px_10px_0px_rgba(0,0,0,0.05)] dark:bg-neutral-800/50"
|
||||
>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{(() => {
|
||||
const randomCategory = [
|
||||
['simple-icons:react', 'React Libraries'],
|
||||
['tabler:database', 'Databases'],
|
||||
['tabler:device-desktop-code', 'UI Frameworks'],
|
||||
['tabler:terminal', 'Command Line Tools'],
|
||||
['tabler:server', 'Servers']
|
||||
][Math.floor(Math.random() * 5)]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'-mt-1 mb-1 flex items-center gap-2 font-medium text-neutral-500'
|
||||
}
|
||||
>
|
||||
<Icon icon={randomCategory[0]} className="h-4 w-4" />
|
||||
<span>{randomCategory[1]}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div className="text-lg font-semibold text-neutral-800">
|
||||
{faker.lorem.words(Math.floor(Math.random() * 5) + 1)}
|
||||
</div>
|
||||
<p className="text-neutral-500">
|
||||
{faker.lorem.paragraphs(1)}
|
||||
</p>
|
||||
</div>
|
||||
<button className="absolute right-4 top-4 rounded-md p-2 text-neutral-100 hover:bg-neutral-700/30 hover:text-neutral-100">
|
||||
<Icon icon="tabler:dots-vertical" className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user