deploy 1
83
.drone.yml
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: ci-check
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- develop
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
steps:
|
||||
- name: install
|
||||
image: node:22-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
|
||||
- name: typecheck
|
||||
image: node:22-alpine
|
||||
commands:
|
||||
- npx tsc --noEmit
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
- name: build
|
||||
image: node:22-alpine
|
||||
commands:
|
||||
- npm run build
|
||||
environment:
|
||||
VITE_API_BASE_URL: https://api.test.bogazicidenizcilik.com
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: ssh
|
||||
name: deploy-test
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- test
|
||||
event:
|
||||
- push
|
||||
|
||||
server:
|
||||
host:
|
||||
from_secret: server_host
|
||||
user:
|
||||
from_secret: server_user
|
||||
ssh_key:
|
||||
from_secret: server_ssh_key
|
||||
|
||||
steps:
|
||||
- name: deploy
|
||||
commands:
|
||||
- cd /opt/projects/bogazici/corporate-admin/test/admin
|
||||
- bash scripts/deploy-test.sh
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: ssh
|
||||
name: deploy-prod
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
|
||||
server:
|
||||
host:
|
||||
from_secret: server_host
|
||||
user:
|
||||
from_secret: server_user
|
||||
ssh_key:
|
||||
from_secret: server_ssh_key
|
||||
|
||||
steps:
|
||||
- name: deploy
|
||||
commands:
|
||||
- cd /opt/projects/bogazici/corporate-admin/prod/admin
|
||||
- bash scripts/deploy-prod.sh
|
||||
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=https://bogazici-api.test
|
||||
VITE_CLERK_PUBLISHABLE_KEY=
|
||||
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
18
.prettierignore
Normal file
@@ -0,0 +1,18 @@
|
||||
# Ignore everything
|
||||
/*
|
||||
|
||||
# Except these files & folders
|
||||
!/src
|
||||
!index.html
|
||||
!package.json
|
||||
!tailwind.config.js
|
||||
!tsconfig.json
|
||||
!tsconfig.node.json
|
||||
!vite.config.ts
|
||||
!.prettierrc
|
||||
!README.md
|
||||
!eslint.config.js
|
||||
!postcss.config.js
|
||||
|
||||
# Ignore auto generated routeTree.gen.ts
|
||||
/src/routeTree.gen.ts
|
||||
50
.prettierrc
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 80,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "lf",
|
||||
"plugins": [
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"tailwindStylesheet": "./src/styles/index.css",
|
||||
"importOrder": [
|
||||
"^path$",
|
||||
"^vite$",
|
||||
"^@vitejs/(.*)$",
|
||||
"^react$",
|
||||
"^react-dom/client$",
|
||||
"^react/(.*)$",
|
||||
"^globals$",
|
||||
"^zod$",
|
||||
"^axios$",
|
||||
"^date-fns$",
|
||||
"^react-hook-form$",
|
||||
"^use-intl$",
|
||||
"^@radix-ui/(.*)$",
|
||||
"^@hookform/resolvers/zod$",
|
||||
"^@tanstack/react-query$",
|
||||
"^@tanstack/react-router$",
|
||||
"^@tanstack/react-table$",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@/assets/(.*)",
|
||||
"^@/api/(.*)$",
|
||||
"^@/stores/(.*)$",
|
||||
"^@/lib/(.*)$",
|
||||
"^@/utils/(.*)$",
|
||||
"^@/constants/(.*)$",
|
||||
"^@/context/(.*)$",
|
||||
"^@/hooks/(.*)$",
|
||||
"^@/components/layouts/(.*)$",
|
||||
"^@/components/ui/(.*)$",
|
||||
"^@/components/errors/(.*)$",
|
||||
"^@/components/(.*)$",
|
||||
"^@/features/(.*)$",
|
||||
"^[./]"
|
||||
]
|
||||
}
|
||||
9
.tanstack/tmp/bb349b93-24924988e841110d87e1d156b478cc73
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/leads/')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/_authenticated/leads/"!</div>
|
||||
}
|
||||
9
.tanstack/tmp/bb349b93-fcffe8e1eaf078efc9ab81e71c949dad
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/faqs/')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/_authenticated/faqs/"!</div>
|
||||
}
|
||||
9
.tanstack/tmp/bb349b93-fd70d3e6c8ea6c909626e832961e1ef4
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/hero-slides/')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/_authenticated/hero-slides/"!</div>
|
||||
}
|
||||
331
CHANGELOG.md
Normal file
@@ -0,0 +1,331 @@
|
||||
## v2.2.1 (2025-11-06)
|
||||
|
||||
### Fix
|
||||
|
||||
- **style**: update data attribute class in authenticated layout (#249)
|
||||
- prevent navigation to 500 page during development (#240)
|
||||
- **style**: apply variant 'destructive' to sign-out buttons (#236)
|
||||
- add missing space in profile form (#235)
|
||||
|
||||
### Refactor
|
||||
|
||||
- enhance tables and update table layout (#234)
|
||||
|
||||
## v2.2.0 (2025-10-09)
|
||||
|
||||
### Feat
|
||||
|
||||
- add analytics tab in dashboard page (#220)
|
||||
- add extra AppTitle component for sidebar header (#216)
|
||||
- update 2-column sign in page (#213)
|
||||
|
||||
### Fix
|
||||
|
||||
- update sidebar menu chevron direction in RTL mode (#229)
|
||||
- pagination button spacing (#215)
|
||||
- upgrade lucide-react to solve antivirus warning (#211)
|
||||
|
||||
### Refactor
|
||||
|
||||
- move sidebar related components into app-sidebar
|
||||
- change SidebarInset component from 'main' to 'div'
|
||||
- replace extra main container query with content container query
|
||||
- replace inline svg logo with logo component (#214)
|
||||
|
||||
## v2.1.0 (2025-08-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- enhance data table pagination with page numbers (#207)
|
||||
- enhance auth flow with sign-out dialogs and redirect functionality (#206)
|
||||
|
||||
### Refactor
|
||||
|
||||
- reorganize utility files into `lib/` folder (#209)
|
||||
- extract data-table components and reorganize structure (#208)
|
||||
|
||||
## v2.0.0 (2025-08-16)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- CSS file structure has been reorganized
|
||||
|
||||
### Feat
|
||||
|
||||
- add search param sync in apps route (#200)
|
||||
- improve tables and sync table states with search param (#199)
|
||||
- add data table bulk action toolbar (#196)
|
||||
- add config drawer and update overall layout (#186)
|
||||
- RTL support (#179)
|
||||
|
||||
### Fix
|
||||
|
||||
- adjust layout styles in search and top nav in dashboard page
|
||||
- update spacing and layout styles
|
||||
- update faceted icon color
|
||||
- improve user table hover & selected styles (#195)
|
||||
- add max-width for large screens to improve responsiveness (#194)
|
||||
- adjust chat border radius for better responsiveness (#193)
|
||||
- update hard-coded or inconsistent colors (#191)
|
||||
- use variable for inset layout height calculation
|
||||
- faded-bottom overflow issue in inset layout
|
||||
- hide unnecessary configs on mobile (#189)
|
||||
- adjust file input text vertical alignment (#188)
|
||||
|
||||
### Refactor
|
||||
|
||||
- enforce consistency and code quality (#198)
|
||||
- improve code quality and consistency (#197)
|
||||
- update error routes (#192)
|
||||
- remove DirSwitch component and its usage in Tasks (#190)
|
||||
- standardize using cookie as persist state (#187)
|
||||
- separate CSS into modular theme and base styles (#185)
|
||||
- replace tabler icons with lucide icons (#183)
|
||||
|
||||
## v1.4.2 (2025-07-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- remove unnecessary transitions in table (#176)
|
||||
- overflow background in tables (#175)
|
||||
|
||||
## v1.4.1 (2025-06-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- user list overflow in chat (#160)
|
||||
- prevent showing collapsed menu on mobile (#155)
|
||||
- white background select dropdown in dark mode (#149)
|
||||
|
||||
### Refactor
|
||||
|
||||
- update font config guide in fonts.ts (#164)
|
||||
|
||||
## v1.4.0 (2025-05-25)
|
||||
|
||||
### Feat
|
||||
|
||||
- **clerk**: add Clerk for auth and protected route (#146)
|
||||
|
||||
### Fix
|
||||
|
||||
- add an indicator for nested pages in search (#147)
|
||||
- update faded-bottom color with css variable (#139)
|
||||
|
||||
## v1.3.0 (2025-04-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- replace custom otp with input-otp component (#131)
|
||||
- disable layout animation on mobile (#130)
|
||||
- upgrade react-day-picker and update calendar component (#129)
|
||||
|
||||
### Others
|
||||
|
||||
- upgrade Tailwind CSS to v4 (#125)
|
||||
- upgrade dependencies (#128)
|
||||
- configure automatic code-splitting (#127)
|
||||
|
||||
## v1.2.0 (2025-04-12)
|
||||
|
||||
### Feat
|
||||
|
||||
- add loading indicator during page transitions (#119)
|
||||
- add light favicons and theme-based switching (#112)
|
||||
- add new chat dialog in chats page (#90)
|
||||
|
||||
### Fix
|
||||
|
||||
- add fallback font for fontFamily (#110)
|
||||
- broken focus behavior in add user dialog (#113)
|
||||
|
||||
## v1.1.0 (2025-01-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- allow changing font family in setting
|
||||
|
||||
### Fix
|
||||
|
||||
- update sidebar color in dark mode for consistent look (#87)
|
||||
- use overflow-clip in table paginations (#86)
|
||||
- **style**: update global scrollbar style (#82)
|
||||
- toolbar filter placeholder typo in user table (#76)
|
||||
|
||||
## v1.0.3 (2024-12-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- add gap between buttons in import task dialog (#70)
|
||||
- hide button sort if column cannot be hidden & update filterFn (#69)
|
||||
- nav links added in profile dropdown (#68)
|
||||
|
||||
### Refactor
|
||||
|
||||
- optimize states in users/tasks context (#71)
|
||||
|
||||
## v1.0.2 (2024-12-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- update overall layout due to scroll-lock bug (#66)
|
||||
|
||||
### Refactor
|
||||
|
||||
- analyze and remove unused files/exports with knip (#67)
|
||||
|
||||
## v1.0.1 (2024-12-14)
|
||||
|
||||
### Fix
|
||||
|
||||
- merge two button components into one (#60)
|
||||
- loading all tabler-icon chunks in dev mode (#59)
|
||||
- display menu dropdown when sidebar collapsed (#58)
|
||||
- update spacing & alignment in dialogs/drawers
|
||||
- update border & transition of sticky columns in user table
|
||||
- update heading alignment to left in user dialogs
|
||||
- add height and scroll area in user mutation dialogs
|
||||
- update `/dashboard` route to just `/`
|
||||
- **build**: replace require with import in tailwind.config.js
|
||||
|
||||
### Refactor
|
||||
|
||||
- remove unnecessary layout-backup file
|
||||
|
||||
## v1.0.0 (2024-12-09)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- Restructured the entire folder
|
||||
hierarchy to adopt a feature-based structure. This
|
||||
change improves code modularity and maintainability
|
||||
but introduces breaking changes.
|
||||
|
||||
### Feat
|
||||
|
||||
- implement task dialogs
|
||||
- implement user invite dialog
|
||||
- implement users CRUD
|
||||
- implement global command/search
|
||||
- implement custom sidebar trigger
|
||||
- implement coming-soon page
|
||||
|
||||
### Fix
|
||||
|
||||
- uncontrolled issue in account setting
|
||||
- card layout issue in app integrations page
|
||||
- remove form reset logic from useEffect in task import
|
||||
- update JSX types due to react 19
|
||||
- prevent card stretch in filtered app layout
|
||||
- layout wrap issue in tasks page on mobile
|
||||
- update user column hover and selected colors
|
||||
- add setTimeout in user dialog closing
|
||||
- layout shift issue in dropdown modal
|
||||
- z-axis overflow issue in header
|
||||
- stretch search bar only in mobile
|
||||
- language dropdown issue in account setting
|
||||
- update overflow contents with scroll area
|
||||
|
||||
### Refactor
|
||||
|
||||
- update layouts and extract common layout
|
||||
- reorganize project to feature-based structure
|
||||
|
||||
## v1.0.0-beta.5 (2024-11-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- add multiple language support (#37)
|
||||
|
||||
### Fix
|
||||
|
||||
- ensure site syncs with system theme changes (#49)
|
||||
- recent sales responsive on ipad view (#40)
|
||||
|
||||
## v1.0.0-beta.4 (2024-09-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- upgrade theme button to theme dropdown (#33)
|
||||
- **a11y**: add "Skip to Main" button to improve keyboard navigation (#27)
|
||||
|
||||
### Fix
|
||||
|
||||
- optimize onComplete/onIncomplete invocation (#32)
|
||||
- solve asChild attribute issue in custom button (#31)
|
||||
- improve custom Button component (#28)
|
||||
|
||||
## v1.0.0-beta.3 (2024-08-25)
|
||||
|
||||
### Feat
|
||||
|
||||
- implement chat page (#21)
|
||||
- add 401 error page (#12)
|
||||
- implement apps page
|
||||
- add otp page
|
||||
|
||||
### Fix
|
||||
|
||||
- prevent focus zoom on mobile devices (#20)
|
||||
- resolve eslint script issue (#18)
|
||||
- **a11y**: update default aria-label of each pin-input
|
||||
- resolve OTP paste issue in multi-digit pin-input
|
||||
- update layouts and solve overflow issues (#11)
|
||||
- sync pin inputs programmatically
|
||||
|
||||
## v1.0.0-beta.2 (2024-03-18)
|
||||
|
||||
### Feat
|
||||
|
||||
- implement custom pin-input component (#2)
|
||||
|
||||
## v1.0.0-beta.1 (2024-02-08)
|
||||
|
||||
### Feat
|
||||
|
||||
- update theme-color meta tag when theme is updated
|
||||
- add coming soon page in broken pages
|
||||
- implement tasks table and page
|
||||
- add remaining settings pages
|
||||
- add example error page for settings
|
||||
- update general error page to be more flexible
|
||||
- implement settings layout and settings profile page
|
||||
- add error pages
|
||||
- add password-input custom component
|
||||
- add sign-up page
|
||||
- add forgot-password page
|
||||
- add box sign in page
|
||||
- add email + password sign in page
|
||||
- make sidebar responsive and accessible
|
||||
- add tailwind prettier plugin
|
||||
- make sidebar collapsed state in local storage
|
||||
- add check current active nav hook
|
||||
- add loader component ui
|
||||
- update dropdown nav by default if child is active
|
||||
- add main-panel in dashboard
|
||||
- **ui**: add dark mode
|
||||
- **ui**: implement side nav ui
|
||||
|
||||
### Fix
|
||||
|
||||
- update incorrect overflow side nav height
|
||||
- exclude shadcn components from linting and remove unused props
|
||||
- solve text overflow issue when nav text is long
|
||||
- replace nav with dropdown in mobile topnav
|
||||
- make sidebar scrollable when overflow
|
||||
- update nav link keys
|
||||
- **ui**: update label style
|
||||
|
||||
### Refactor
|
||||
|
||||
- move password-input component into custom component dir
|
||||
- add custom button component
|
||||
- extract redundant codes into layout component
|
||||
- update react-router to use new api for routing
|
||||
- update main panel layout
|
||||
- update major layouts and styling
|
||||
- update main panel to be responsive
|
||||
- update sidebar collapsed state to false in mobile
|
||||
- update sidebar logo and title
|
||||
- **ui**: remove unnecessary spacing
|
||||
- remove unused files
|
||||
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_BASE_URL
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Sat Naing
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
229
README.md
@@ -1,119 +1,174 @@
|
||||
# Shadcn Admin Dashboard
|
||||
# Bogazici Admin Dashboard
|
||||
|
||||
Admin Dashboard UI crafted with Shadcn and Vite. Built with responsiveness and accessibility in mind.
|
||||
Bogazici Denizcilik yonetim paneli. Kurs, duyuru, sayfa, kullanici ve icerik yonetimi icin gelistirilmis kapsamli bir CMS admin uygulamasidir.
|
||||
|
||||

|
||||
## Ozellikler
|
||||
|
||||
[](https://go.clerk.com/GttUAaK)
|
||||
### Icerik Yonetimi
|
||||
|
||||
I've been creating dashboard UIs at work and for my personal projects. I always wanted to make a reusable collection of dashboard UI for future projects; and here it is now. While I've created a few custom components, some of the code is directly adapted from ShadcnUI examples.
|
||||
- **Kurslar** - Kurs olusturma, duzenleme, kategori atama, fiyatlandirma, gorsel yukleme
|
||||
- **Kurs Takvimleri** - Takvim planlamasi, kontenjan takibi, egitmen atama
|
||||
- **Kategoriler** - Kurslari kategorilere ayirma, slug, SEO meta bilgileri
|
||||
- **Duyurular** - Blog tarzinda duyurular, zengin metin editoru, one cikan gorseller
|
||||
- **Sayfalar** - Blok tabanli sayfa olusturucu ile dinamik CMS sayfalari
|
||||
- **SSS** - Sikca sorulan sorular yonetimi
|
||||
- **Basari Hikayeleri** - Musteri referanslari ve basari hikayeleri
|
||||
|
||||
> This is not a starter project (template) though. I'll probably make one in the future.
|
||||
### Site Yonetimi
|
||||
|
||||
## Features
|
||||
- **Hero Slider** - Ana sayfa karousel yonetimi (gorsel ve video destegi)
|
||||
- **Menuler** - Hiyerarsik navigasyon menu yonetimi
|
||||
- **Rehber Kartlari** - Bilgilendirme kartlari yonetimi
|
||||
- **Ayarlar** - Sistem genelinde yapilandirma
|
||||
|
||||
- Light/dark mode
|
||||
- Responsive
|
||||
- Accessible
|
||||
- With built-in Sidebar component
|
||||
- Global search command
|
||||
- 10+ pages
|
||||
- Extra custom components
|
||||
- RTL support
|
||||
### Kullanici ve Erisim
|
||||
|
||||
<details>
|
||||
<summary>Customized Components (click to expand)</summary>
|
||||
- **Kullanici Yonetimi** - Admin kullanicilari CRUD islemleri, rol atama
|
||||
- **Rol Tabanli Erisim Kontrolu (RBAC)** - Modullere gore ayrilmis detayli izin tanimlari
|
||||
- **Lead Yonetimi** - Farkli kaynaklardan (form, WhatsApp) gelen leadlerin takibi, UTM parametreleri, KVKK onay takibi
|
||||
- **Yorum Moderasyonu** - Yorumlari onaylama/reddetme, admin yaniti
|
||||
|
||||
This project uses Shadcn UI components, but some have been slightly modified for better RTL (Right-to-Left) support and other improvements. These customized components differ from the original Shadcn UI versions.
|
||||
### Arayuz
|
||||
|
||||
If you want to update components using the Shadcn CLI (e.g., `npx shadcn@latest add <component>`), it's generally safe for non-customized components. For the listed customized ones, you may need to manually merge changes to preserve the project's modifications and avoid overwriting RTL support or other updates.
|
||||
- Acik/karanlik/sistem tema destegi
|
||||
- Responsive tasarim
|
||||
- Komut paleti (Cmd/Ctrl+K) ile hizli navigasyon
|
||||
- Siralama, filtreleme ve sayfalama destekli veri tablolari
|
||||
- Zengin metin editoru (TipTap)
|
||||
- Gorsel ve video yukleme
|
||||
- Erisebilirlik (a11y) destegi
|
||||
- RTL/LTR dil yonu destegi
|
||||
- Toast bildirimleri ve ilerleme gostergeleri
|
||||
|
||||
> If you don't require RTL support, you can safely update the 'RTL Updated Components' via the Shadcn CLI, as these changes are primarily for RTL compatibility. The 'Modified Components' may have other customizations to consider.
|
||||
## Teknoloji Yigini
|
||||
|
||||
### Modified Components
|
||||
| Katman | Teknoloji |
|
||||
|---|---|
|
||||
| **UI Framework** | [React 19](https://react.dev) |
|
||||
| **Build** | [Vite 7](https://vitejs.dev) + [SWC](https://swc.rs) |
|
||||
| **Bilesenler** | [Shadcn UI](https://ui.shadcn.com) (TailwindCSS + Radix UI) |
|
||||
| **Routing** | [TanStack Router](https://tanstack.com/router) (dosya tabanli, otomatik code splitting) |
|
||||
| **Sunucu Durumu** | [TanStack React Query](https://tanstack.com/query) |
|
||||
| **Istemci Durumu** | [Zustand](https://zustand.docs.pmnd.rs) |
|
||||
| **Formlar** | [React Hook Form](https://react-hook-form.com) + [Zod](https://zod.dev) |
|
||||
| **Zengin Metin** | [TipTap](https://tiptap.dev) |
|
||||
| **Grafikler** | [Recharts](https://recharts.org) |
|
||||
| **Tablolar** | [TanStack Table](https://tanstack.com/table) |
|
||||
| **HTTP** | [Axios](https://axios-http.com) (Bearer token interceptor) |
|
||||
| **Stil** | [TailwindCSS 4](https://tailwindcss.com) |
|
||||
| **Ikonlar** | [Lucide](https://lucide.dev) |
|
||||
| **Dil** | [TypeScript](https://www.typescriptlang.org) |
|
||||
| **Lint/Format** | [ESLint](https://eslint.org) + [Prettier](https://prettier.io) |
|
||||
|
||||
- scroll-area
|
||||
- sonner
|
||||
- separator
|
||||
## Proje Yapisi
|
||||
|
||||
### RTL Updated Components
|
||||
|
||||
- alert-dialog
|
||||
- calendar
|
||||
- command
|
||||
- dialog
|
||||
- dropdown-menu
|
||||
- select
|
||||
- table
|
||||
- sheet
|
||||
- sidebar
|
||||
- switch
|
||||
|
||||
**Notes:**
|
||||
|
||||
- **Modified Components**: These have general updates, potentially including RTL adjustments.
|
||||
- **RTL Updated Components**: These have specific changes for RTL language support (e.g., layout, positioning).
|
||||
- For implementation details, check the source files in `src/components/ui/`.
|
||||
- All other Shadcn UI components in the project are standard and can be safely updated via the CLI.
|
||||
|
||||
</details>
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**UI:** [ShadcnUI](https://ui.shadcn.com) (TailwindCSS + RadixUI)
|
||||
|
||||
**Build Tool:** [Vite](https://vitejs.dev/)
|
||||
|
||||
**Routing:** [TanStack Router](https://tanstack.com/router/latest)
|
||||
|
||||
**Type Checking:** [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**Linting/Formatting:** [ESLint](https://eslint.org/) & [Prettier](https://prettier.io/)
|
||||
|
||||
**Icons:** [Lucide Icons](https://lucide.dev/icons/), [Tabler Icons](https://tabler.io/icons) (Brand icons only)
|
||||
|
||||
**Auth (partial):** [Clerk](https://go.clerk.com/GttUAaK)
|
||||
|
||||
## Run Locally
|
||||
|
||||
Clone the project
|
||||
|
||||
```bash
|
||||
git clone https://github.com/satnaing/shadcn-admin.git
|
||||
```
|
||||
src/
|
||||
├── routes/ # TanStack Router dosya tabanli yonlendirme
|
||||
│ ├── (auth)/ # Giris sayfalari
|
||||
│ ├── _authenticated/ # Korunmus rotalar (auth guard)
|
||||
│ │ ├── dashboard/ # Ana panel
|
||||
│ │ ├── courses/ # Kurs yonetimi
|
||||
│ │ ├── categories/ # Kategori yonetimi
|
||||
│ │ ├── schedules/ # Takvim yonetimi
|
||||
│ │ ├── announcements/ # Duyuru yonetimi
|
||||
│ │ ├── pages/ # Sayfa yonetimi (CMS)
|
||||
│ │ ├── users/ # Kullanici yonetimi
|
||||
│ │ ├── roles/ # Rol yonetimi
|
||||
│ │ ├── leads/ # Lead yonetimi
|
||||
│ │ ├── comments/ # Yorum moderasyonu
|
||||
│ │ ├── hero-slides/ # Hero slider
|
||||
│ │ ├── faqs/ # SSS
|
||||
│ │ ├── menus/ # Menu yonetimi
|
||||
│ │ ├── guide-cards/ # Rehber kartlari
|
||||
│ │ ├── stories/ # Basari hikayeleri
|
||||
│ │ └── settings/ # Ayarlar
|
||||
│ └── (errors)/ # Hata sayfalari (403, 404, 500)
|
||||
├── features/ # Ozellik modulleri (her module CRUD islemleri)
|
||||
├── components/
|
||||
│ ├── ui/ # Shadcn UI bilesenler (40+)
|
||||
│ ├── layout/ # Sidebar, header, ana layout
|
||||
│ ├── data-table/ # Yeniden kullanilabilir tablo bilesenleri
|
||||
│ └── block-manager/ # Sayfa blok yonetimi
|
||||
├── lib/api/ # API istemcisi ve modul bazli API fonksiyonlari
|
||||
├── stores/ # Zustand durum yonetimi
|
||||
├── context/ # React context (tema, font, yon, layout)
|
||||
└── hooks/ # Ozel React hooklari
|
||||
```
|
||||
|
||||
Go to the project directory
|
||||
## Kurulum
|
||||
|
||||
### Gereksinimler
|
||||
|
||||
- Node.js 22+
|
||||
- pnpm
|
||||
|
||||
### Yerel Gelistirme
|
||||
|
||||
```bash
|
||||
cd shadcn-admin
|
||||
```
|
||||
# Projeyi klonla
|
||||
git clone <repo-url>
|
||||
cd bogazici-admin
|
||||
|
||||
Install dependencies
|
||||
# Ortam degiskenlerini ayarla
|
||||
cp .env.example .env
|
||||
# .env dosyasinda VITE_API_BASE_URL degerini duzenle
|
||||
|
||||
```bash
|
||||
# Bagimliliklari yukle
|
||||
pnpm install
|
||||
|
||||
# Gelistirme sunucusunu baslat
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Start the server
|
||||
### Mevcut Betikler
|
||||
|
||||
| Betik | Aciklama |
|
||||
|---|---|
|
||||
| `pnpm dev` | Gelistirme sunucusu |
|
||||
| `pnpm build` | TypeScript kontrolu + uretim derlemesi |
|
||||
| `pnpm preview` | Uretim derlemesini onizle |
|
||||
| `pnpm lint` | ESLint ile kod kontrolu |
|
||||
| `pnpm format` | Prettier ile otomatik formatlama |
|
||||
| `pnpm format:check` | Format kontrolu |
|
||||
| `pnpm knip` | Kullanilmayan dosya/bagimlilik tespiti |
|
||||
|
||||
### Ortam Degiskenleri
|
||||
|
||||
| Degisken | Aciklama |
|
||||
|---|---|
|
||||
| `VITE_API_BASE_URL` | Backend API adresi (orn: `https://bogazici-api.test`) |
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
Proje, multi-stage Docker build ile Nginx uzerinde sunulmaktadir.
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
# Uretim imajini olustur
|
||||
docker build --build-arg VITE_API_BASE_URL=https://api.bogazicidenizcilik.com -t bogazici-admin .
|
||||
|
||||
# Calistir
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Sponsoring this project ❤️
|
||||
### CI/CD (DroneCI)
|
||||
|
||||
If you find this project helpful or use this in your own work, consider [sponsoring me](https://github.com/sponsors/satnaing) to support development and maintenance. You can [buy me a coffee](https://buymeacoffee.com/satnaing) as well. Don’t worry, every penny helps. Thank you! 🙏
|
||||
| Dal | Islem |
|
||||
|---|---|
|
||||
| `develop` | TypeScript kontrolu + build dogrulamasi |
|
||||
| `test` | Test sunucusuna otomatik deploy |
|
||||
| `main` | Uretim sunucusuna otomatik deploy |
|
||||
|
||||
For questions or sponsorship inquiries, feel free to reach out at [satnaingdev@gmail.com](mailto:satnaingdev@gmail.com).
|
||||
## Kimlik Dogrulama
|
||||
|
||||
### Current Sponsor
|
||||
- Email/sifre ile giris (`/sign-in`)
|
||||
- JWT Bearer token tabanli kimlik dogrulama
|
||||
- Token ve kullanici bilgisi Zustand store + cookie’lerde saklanir
|
||||
- 401 yaniti alindiginda otomatik oturum sonlandirma
|
||||
- Tum `/_authenticated` rotalari gecerli token gerektirir
|
||||
|
||||
- [Clerk](https://go.clerk.com/GttUAaK) - authentication and user management for the modern web
|
||||
## Lisans
|
||||
|
||||
## Author
|
||||
|
||||
Crafted with 🤍 by [@satnaing](https://github.com/satnaing)
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT License](https://choosealicense.com/licenses/mit/)
|
||||
[MIT](https://choosealicense.com/licenses/mit/)
|
||||
|
||||
4325
api-docs.json
Normal file
802
backend_ve_admin_dokuman.md
Normal file
@@ -0,0 +1,802 @@
|
||||
# Boğaziçi Denizcilik - Backend (Laravel) & Admin Panel (Next.js) Gereksinim Dökümanı (v2)
|
||||
|
||||
Bu döküman, mevcut Next.js (Frontend) projenizin dinamik veri altyapısını bir **Laravel API**'sine bağlamak ve bu verileri yönetmek için tamamen ayrı bir **Next.js Admin Panel** geliştirmek üzere hazırlanmıştır.
|
||||
|
||||
*(v2 Güncellemesi: Gelişmiş Setting altyapısı, Pagination standartları, Modüler Page Builder API'leri, Soft Deletes, Caching, Audit Logs, Rate Limiting ve Çok Dilli (i18n) destek notları eklenmiştir.)*
|
||||
|
||||
> **Çok Dilli (i18n) Altyapı Notu:** Şimdilik v1 aşamasında tek dil (TR) olarak çıkılacaktır. Ancak v2+ versiyonları için i18n (Çoklu dil) planlanıyorsa, veritabanı tablolarında JSON field veya harici translations tablosu yaklaşımı değerlendirilecektir.
|
||||
|
||||
> **🤖 GitHub Copilot (VS Code) İçin Geliştirme Talimatı (Master Prompt):**
|
||||
> *Laravel Herd ile kendi oluşturduğunuz projeyi VS Code'da açtıktan sonra, Copilot Chat'e (veya Cursor/Cline'a) şu mesajı yazmanız yeterlidir:*
|
||||
>
|
||||
> "Merhaba Copilot, bu projeyi **Laravel 12+** ile geliştireceğiz. Lütfen ana dizindeki **`backend_ve_admin_dokuman.md`** dosyasını dikkatlice baştan sona oku ve mimariyi kavra.
|
||||
> Ardından şu işlemleri benim onayımı alarak **adım adım** kodlamaya başla:
|
||||
> 1. Önce "1. Veritabanı Şeması" bölümündeki tüm tablolar için Laravel Migration, Model ve Factory dosyalarını oluştur. Önemli tablolarda SoftDeletes kullan. Bu adımı bitirince onayımı bekle.
|
||||
> 2. Ardından "REST API" ve "Klasör Mimarisi" bölümüne geç. Kesinlikle spagetti controller yazma. Her modül için sırayla; `FormRequest`, `DTO`, `Action (Service)`, `Repository` ve en son `Controller` ile `api.php` rotalarını oluştur. Response standartlarına ve Caching kurallarına dikkat et.
|
||||
> 3. Her modülü (Örn: Courses, Categories) tek tek kodla ve her modül bitiminde bana bilgi ver. Asla tüm projeyi tek seferde yazmaya çalışma."
|
||||
|
||||
---
|
||||
|
||||
## 1. Veritabanı Şeması (Laravel Modelleri & Migrations)
|
||||
|
||||
*(Güvenlik: Kaza eseri veri kayıplarını önlemek için yetki, kurs, kategori ve lead tablolarında `SoftDeletes` kullanılacaktır.)*
|
||||
|
||||
### 1.0. `users` & `roles` (Yetkilendirme / Auth)
|
||||
- Laravel'in standart `users` tablosu. Admin panele giriş yapacak personeli temsil eder.
|
||||
- Sisteme **Spatie Permission** paketi kurularak Role ve Yetki altyapısı tanımlanacaktır (Örn: `super-admin`, `editor`).
|
||||
- `SoftDeletes` aktif olmalıdır.
|
||||
|
||||
### 1.1. `categories` (Eğitim Kategorileri)
|
||||
- `id` (PK)
|
||||
- `slug` (string, unique) - *Örn: guverte*
|
||||
- `label` (string) - *Örn: Güverte Eğitimleri*
|
||||
- `desc` (text, nullable)
|
||||
- `image` (string, nullable) - *Sadece görsel path'i tutar*
|
||||
- `meta_title` (string, nullable) - *SEO Title*
|
||||
- `meta_description` (text, nullable) - *SEO Description*
|
||||
- `created_at`, `updated_at`, `deleted_at` *(SoftDeletes)*
|
||||
|
||||
### 1.2. `courses` (Eğitimler/Kurslar)
|
||||
- `id` (PK)
|
||||
- `category_id` (FK -> categories.id)
|
||||
- `slug` (string, unique)
|
||||
- `title` (string)
|
||||
- `sub` (string, nullable) - *Örn: STCW II/1*
|
||||
- `desc` (text) - *Kısa açıklama*
|
||||
- `long_desc` (longText) - *Detaylı açıklama*
|
||||
- `duration` (string) - *Örn: 5 Gün*
|
||||
- `students` (integer, default: 0) - *Kayıtlı öğrenci sayısı*
|
||||
- `rating` (decimal: 2,1, default: 5.0)
|
||||
- `badge` (string, nullable) - *Örn: Simülatör*
|
||||
- `image` (string, nullable) - *Sadece path tutar*
|
||||
- `price` (string, nullable)
|
||||
- `includes` (json, nullable) - *Fiyata dahil olanlar listesi. Örn: `["Eğitim materyali", "Sertifika ücreti", "İkram"]`*
|
||||
- `requirements` (json, nullable) - *Katılım koşulları/gereksinimler listesi. Örn: `["18 yaşını doldurmuş olmak", "Lise mezunu olmak"]`*
|
||||
- `meta_title` (string, nullable) - *SEO Title*
|
||||
- `meta_description` (text, nullable) - *SEO Description*
|
||||
- `scope` (json, nullable) - *Eğitim kapsamı konu başlıkları dizisi. Örn: `["Ana makine sistemleri", "Yağlama ve soğutma sistemleri", "Yakıt sistemleri"]`*
|
||||
- `standard` (string, nullable) - *Uyum standardı. Örn: `"STCW / IMO Uyumlu"`*
|
||||
- `language` (string, nullable, default: `"Türkçe"`) - *Eğitim dili*
|
||||
- `location` (string, nullable) - *Kursun varsayılan lokasyonu. Örn: `"Kadıköy, İstanbul"`*
|
||||
- `created_at`, `updated_at`, `deleted_at` *(SoftDeletes)*
|
||||
|
||||
### 1.3. `course_schedules` (Takvim / Planlanan Eğitimler)
|
||||
- `id` (PK)
|
||||
- `course_id` (FK -> courses.id)
|
||||
- `start_date` (date) - *Örn: 2026-02-24*
|
||||
- `end_date` (date) - *Örn: 2026-02-28*
|
||||
- `location` (string) - *Örn: Kadıköy veya Online*
|
||||
- `quota` (integer) - *Toplam Kontenjan*
|
||||
- `available_seats` (integer) - *Kalan Koltuk (Otomatik `quota - reserved_count` mantığıyla çalışmalı veya admin üzerinden manuel update edilmeli. Proje ilerleyişine göre netleşecek).*
|
||||
- `is_urgent` (boolean, default: false) - *Sınırlı Kontenjan uyarısı*
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### 1.4. `announcements` (Duyurular & Haberler)
|
||||
- `id` (PK)
|
||||
- `slug` (string, unique)
|
||||
- `title` (string)
|
||||
- `category` (string, enum) - *Tutarsızlığı önlemek için Enum kullanılmalı: `announcement` | `news` | `event`*
|
||||
- `excerpt` (text)
|
||||
- `content` (longText)
|
||||
- `image` (string, nullable)
|
||||
- `is_featured` (boolean, default: false)
|
||||
- `meta_title` (string, nullable)
|
||||
- `meta_description` (text, nullable)
|
||||
- `published_at` (timestamp) - *Frontend'deki day/month yerine gerçek tarih tutulmalı*
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### 1.5. `hero_slides` (Ana Sayfa Slider)
|
||||
- `id` (PK)
|
||||
- `label` (string) - *Örn: STCW Sertifikasyonu*
|
||||
- `title` (string) - *Satır başı için \n destekli*
|
||||
- `description` (text)
|
||||
- `image` (string, nullable)
|
||||
- `order` (integer, default: 0)
|
||||
- `is_active` (boolean, default: true)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### 1.6. `leads` (Ön Kayıtlar / Form Başvuruları)
|
||||
- `id` (PK)
|
||||
- `name` (string)
|
||||
- `phone` (string)
|
||||
- `target_course` (string, nullable) - *İlgilenilen eğitim*
|
||||
- `education_level` (string, nullable) - *Öğrenim durumu*
|
||||
- `subject` (string, nullable) - *İlgilenilen konu (Danışmanlık formu için Örn: stcw, kaptanlik, belge, diger)*
|
||||
- `message` (text, nullable) - *Ziyaretçinin ek mesajı (Danışmanlık formu için)*
|
||||
- `status` (string, default: 'new') - *`new`, `contacted`, `enrolled`, `rejected`*
|
||||
- `notes` (text, nullable) - *Admin notları*
|
||||
- `is_read` (boolean, default: false) - *Admin panelde okunmamışları vurgulamak için*
|
||||
- `source` (string) - *`hero_form` | `contact_form` | `consultation_form` | `whatsapp_widget`*
|
||||
- `utm` (json, nullable) - *`utm_source`, `utm_campaign`, vb.*
|
||||
- `consent_kvkk` (boolean, default: false)
|
||||
- `consent_text_version` (string, nullable)
|
||||
- `created_at`, `updated_at`, `deleted_at` *(SoftDeletes)*
|
||||
|
||||
### 1.7. `menus` (Dinamik Menü & Navigasyon Yönetimi)
|
||||
Tüm site üst (Navbar) ve alt (Footer) bağlantılarının, Mega Menü içeriklerinin admin panelinden yönetilebilmesi için.
|
||||
- `id` (PK)
|
||||
- `location` (string) - *Örn: `header_main`, `footer_corporate`, `footer_education`, `footer_quicklinks`*
|
||||
- `label` (string) - *Menüde Gözükecek Metin Örn: "Hakkımızda"*
|
||||
- `url` (string) - *Örn: "/kurumsal/hakkimizda"*
|
||||
- `type` (string, default: 'link') - *`link` (Normal Link) | `mega_menu_education` (Özel Hover Mega Menü) | `mega_menu_calendar` (Takvim Dropdown)*
|
||||
- `parent_id` (integer, nullable) - *Alt menüler için (Self Referencing FK)*
|
||||
- `order` (integer, default: 0) - *Sıralama*
|
||||
- `is_active` (boolean, default: true)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### 1.8. `comments` (Eğitim ve Blog Yorumları / Soru-Cevap)
|
||||
*Kullanıcıların üye olmadan, sadece Ad, Soyad ve Telefon numarası girerek Eğitimlere veya Duyurulara/Bloglara yorum yapabileceği ve adminlerin cevaplayabileceği sistem.*
|
||||
- `id` (PK)
|
||||
- `commentable_id` (integer) - *Hangi eğitime/duyuruya yapıldı? (Polymorphic Relation)*
|
||||
- `commentable_type` (string) - *`App\\Models\\Course` veya `App\\Models\\Announcement`*
|
||||
- `name_surname` (string) - *Kullanıcının Adı Soyadı*
|
||||
- `phone` (string) - *Kullanıcının Telefon Numarası (Soru sorana ulaşmak gerekirse)*
|
||||
- `body` (text) - *Yorum / Soru metni*
|
||||
- `admin_reply` (text, nullable) - *Admin'in verdiği cevap (Önyüzde Admin Yanıtı olarak gözükecek)*
|
||||
- `is_approved` (boolean, default: false) - *Admin onaylamadan sitede gözükmemeli*
|
||||
- `created_at`, `updated_at`, `deleted_at` *(SoftDeletes)*
|
||||
|
||||
### 1.9. `faqs` (Sıkça Sorulan Sorular)
|
||||
*Frontend'deki `/sss` sayfası ve anasayfadaki FAQ bileşeni için. Adminler soru-cevapları kategorili olarak yönetebilir.*
|
||||
- `id` (PK)
|
||||
- `category` (string) - *`egitimler` | `kayit` | `iletisim` (Tab bazlı filtreleme için)*
|
||||
- `question` (text) - *Soru metni*
|
||||
- `answer` (text) - *Cevap metni*
|
||||
- `order` (integer, default: 0) - *Kategori içi sıralama*
|
||||
- `is_active` (boolean, default: true) - *Pasif olanlar önyüzde gösterilmez*
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### 1.10. `guide_cards` (Eğitim Rehberi Kartları)
|
||||
*Frontend'deki `/egitim-rehberi` sayfasındaki kariyer yönlendirme kartları için. Admin panelden yönetilebilir.*
|
||||
- `id` (PK)
|
||||
- `title` (string) - *Örn: "Yeni Başlayanlar", "Yat Kaptanları"*
|
||||
- `description` (text) - *Kart içeriği açıklaması*
|
||||
- `icon` (string) - *Lucide icon adı (Örn: `anchor`, `compass`, `shield`, `briefcase`)*
|
||||
- `color` (string) - *Gradient renk sınıfı (Örn: `from-blue-500 to-blue-700`)*
|
||||
- `link` (string) - *Yönlendirme URL'i (Örn: `/egitimler/stcw`)*
|
||||
- `order` (integer, default: 0) - *Sıralama*
|
||||
- `is_active` (boolean, default: true)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### 1.11. `settings` (Sistem Ayarları)
|
||||
*Admin panelinde otomatik tab/sekme yapısı kurmak ve public/private yalıtımı sağlamak için güçlendirilmiş yapı. Logo, sosyal medya linkleri gibi statik veriler buradan yönetilir.*
|
||||
- `id` (PK)
|
||||
- `group` (string) - *`general` | `contact` | `social` | `seo` | `forms` | `footer` | `guide`*
|
||||
- `key` (string, unique)
|
||||
- `value` (text/json, nullable)
|
||||
- `type` (string) - *`text` | `textarea` | `image` | `boolean` | `json` | `richtext`*
|
||||
- `is_public` (boolean, default: true) - *Public API'de görünsün mü? (Örn: `hero_form_email` gizli kalmalıdır).*
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
**Önerilen Settings Key Listesi (Otomatik Seeder ile Girecekler):**
|
||||
- **Genel:** `site_name`, `site_logo`, `footer_logo`, `favicon`
|
||||
- **Anasayfa İçerikleri (Sabit Alanlar):** `home_about_title`, `home_about_text`, `home_about_image_1`, `home_about_image_2`, `home_stats_students`, `home_stats_instructors`, `home_stats_courses` (Anasayfadaki "Biz Kimiz", "Kurum Hakkında" veya istatistik alanlarındaki resim/yazıların yönetimi için).
|
||||
- **İletişim:** `contact_email`, `contact_cc_emails`, `phone_1`, `phone_2`, `whatsapp_phone`, `address`, `google_maps_embed_url`, `working_hours`
|
||||
- **Sosyal Medya (Dinamik Logolar / Linkler):** `instagram_url`, `facebook_url`, `tiktok_url`, `x_url`, `youtube_url`, `linkedin_url` (Bu alanlar boş bırakılırsa önyüzde iconlar gizlenmelidir).
|
||||
- **SEO:** `default_meta_title`, `default_meta_description`, `canonical_base`, `og_default_image`, `robots_indexing_enabled`
|
||||
- **Formlar / KVKK / Kampanya:** `lead_notify_email`, `kvkk_text`, `privacy_policy_url`, `newsletter_title`, `newsletter_desc` (E-bülten ve "Sana özel kampanyalar" kutusundaki metinler).
|
||||
- **Eğitim Rehberi:** `guide_page_title`, `guide_page_subtitle`, `guide_cta_title`, `guide_cta_text`, `guide_cta_button_text`, `guide_cta_button_url` (Eğitim Rehberi sayfasındaki başlık, alt metin ve CTA alanlarının yönetimi).
|
||||
- **Danışmanlık:** `consultation_page_title`, `consultation_page_subtitle`, `consultation_form_title` (Danışmanlık sayfasının başlık ve açıklama alanlarının yönetimi).
|
||||
|
||||
---
|
||||
|
||||
## 2. Laravel REST API Uç Noktaları (Endpoints) & Standartları
|
||||
|
||||
**Cevap (Response) Format Standardı:**
|
||||
Frontend tarafının hızlı ilerlemesi için tüm başarılı API listeleme talepleri paginate ve metadata içermeli, Laravel Resource üzerinden standartlaştırılmalıdır.
|
||||
- Liste endpoint'leri: `{ "data": [], "meta": { "total": 0 }, "links": { } }`
|
||||
- Tekil endpoint'ler: `{ "data": { } }`
|
||||
|
||||
**Parametrik Arama, Filtreleme, Sıralama:**
|
||||
- `GET /api/v1/courses?category=guverte&search=stcw&sort=title&page=1`
|
||||
- `GET /api/v1/announcements?category=news&featured=1&sort=-published_at&page=1`
|
||||
|
||||
### Public API (Frontend İçin - `api/v1/...`)
|
||||
*(Performans için aktif olan sık kullanılan Public GET endpoint'leri **Laravel Cache / Redis** arkasında çalıştırılmalıdır. Veri Admin Panel'den güncellenince Cache Event ile temizlenmelidir.)*
|
||||
|
||||
- `GET /api/v1/settings` -> `is_public=true` olan ayarları döner.
|
||||
- `GET /api/v1/hero-slides` -> Aktif slider içeriklerini sıralı getirir.
|
||||
- `GET /api/v1/menus` -> Header ve Footer navigasyon ağacını hiyerarşik getirir.
|
||||
- `GET /api/v1/categories` -> Kategorileri listeler.
|
||||
- `GET /api/v1/courses` -> Eğitimleri listeler.
|
||||
- `GET /api/v1/courses/{slug}` -> Tekil eğitim detayı.
|
||||
- `GET /api/v1/schedules` -> Yaklaşan takvim eğitimlerini getirir. (Tarihe göre sıralı)
|
||||
- `GET /api/v1/announcements` -> Duyuruları (featured olanlar dahil) getirir.
|
||||
- `GET /api/v1/announcements/{slug}` -> Tekil duyuru detayı.
|
||||
- `GET /api/v1/comments/{type}/{id}` -> Bir kursun veya blogun onaylanmış (`is_approved=true`) yorumlarını ve admin cevaplarını getirir.
|
||||
- `POST /api/v1/comments` -> Ziyaretçiden yeni yorum/soru kaydı alır (Spam/Rate Limiting zorunlu).
|
||||
- `GET /api/v1/faqs` -> Aktif SSS verilerini kategorili olarak getirir. `?category=egitimler` filtresi desteklenmeli.
|
||||
- `GET /api/v1/guide-cards` -> Eğitim Rehberi kartlarını sıralı getirir.
|
||||
- `GET /api/v1/pages/{slug}` -> Page builder bloklarını sırayla (`order_index`) getirir. *(Sadece `/page/` altındaki dinamik sayfalar için)*
|
||||
- `POST /api/v1/leads` -> Ön kayıt formu alımı. `source` alanı ile form kaynağı (`hero_form`, `contact_form`, `consultation_form`) belirlenir. *(Spam koruması için IP bazlı Rate Limiting - Throttle uygulanmalıdır. Dakikada maks 2-3 gibi)*
|
||||
- `GET /api/v1/sitemap-data` -> Frontend'in dinamik `sitemap.xml` oluşturması için tüm public slug'ları ve `updated_at` tarihlerini döner. *(Bkz: Bölüm 7.4)*
|
||||
|
||||
### Protected API (Admin Panel İçin - `api/admin/...`)
|
||||
Tüm yetkili işlemler burada yalıtılmıştır.
|
||||
- `POST /api/admin/login` -> Bearer token döndürür (Sanctum/Passport).
|
||||
- `GET /api/admin/me` -> Giriş yapan admin detaylarını + Spatie Permission rollerini/izinlerini döner.
|
||||
- `POST /api/admin/uploads` -> Multipart/form-data kabul eder. Standart resim yükleme ucudur. DB'ye URL değil **path** kaydeder. Response: `{ "path": "uploads/...", "url": "https://.../uploads/..." }`
|
||||
- Modüller için tüm standart `POST` (Oluşturma), `PUT/PATCH` (Güncelleme), `DELETE` (Silme) uçları.
|
||||
- Menü Yönetimi:
|
||||
- `CRUD /api/admin/menus` -> Menü ekleme ve ağaç dökümü
|
||||
- `POST /api/admin/menus/reorder` -> Sürükle-Bırak sonrası hiyerarşiyi (parent_id ve order) toplu güncelleme
|
||||
- SSS (FAQ) Yönetimi:
|
||||
- `CRUD /api/admin/faqs` -> Soru ekleme, düzenleme, silme, listeleme
|
||||
- Eğitim Rehberi Kartları:
|
||||
- `CRUD /api/admin/guide-cards` -> Kart ekleme, düzenleme, silme, listeleme
|
||||
- Page Builder Admin uçları:
|
||||
- `CRUD /api/admin/pages`
|
||||
- `CRUD /api/admin/pages/{id}/blocks`
|
||||
- `POST /api/admin/pages/{id}/blocks/reorder` -> Sürükle bırak ile sıra (`order_index`) güncelleme.
|
||||
|
||||
---
|
||||
|
||||
## 3. Laravel Katmanlı Mimari (Repository Pattern) Uygulama Standartları
|
||||
|
||||
Backend kodlanırken kesinlikle **Spagetti Controller** (Controller içine direkt `Course::create()` yazmak) kullanılmayacaktır. Tüm iş mantığı ve veritabanı sorguları şu katmanlara ayrılmalıdır:
|
||||
|
||||
**Önerilen Devasa (Enterprise) Klasör Mimarisi (`app/`):**
|
||||
```text
|
||||
app/
|
||||
├── Http/
|
||||
│ ├── Controllers/
|
||||
│ │ └── Api/
|
||||
│ │ └── CourseController.php # HTTP İsteğini alır, DTO'yu oluşturup Action'a yollar. Geriye Response döner.
|
||||
│ ├── Requests/
|
||||
│ │ └── StoreCourseRequest.php # Gelen verilerin validasyon zorunluluklarını (required, string) yapar.
|
||||
│ └── Resources/
|
||||
│ └── CourseResource.php # {YENİ} Frontend'e gidecek JSON formatını filtreler ve standartlaştırır (Presenter).
|
||||
├── DTOs/
|
||||
│ └── CourseData.php # Validasyonu geçmiş temiz veriyi, tiplendirilmiş Nesneye (DTO) dönüştürür.
|
||||
├── Actions/ (veya Services/)
|
||||
│ └── Course/
|
||||
│ └── CreateCourseAction.php # Tüm "İş Mantığı". İndirim hesapla, Event fırlat, Repository'e yolla.
|
||||
├── Repositories/
|
||||
│ ├── Contracts/
|
||||
│ │ └── CourseRepositoryInterface.php # Sınırlar ve Kurallar (Interface)
|
||||
│ └── Eloquent/
|
||||
│ └── CourseRepository.php # Veritabanı (DB) sorguları sadece buradadır (Course::create()).
|
||||
├── Models/
|
||||
│ └── Course.php # Tablo yapısı, İlişkiler (Relations)
|
||||
├── Enums/
|
||||
│ └── CourseStatus.php # Güvenli Sabitler
|
||||
├── Events/
|
||||
│ └── CourseCreatedEvent.php # Asenkron Olaylar (Decoupling için) -> Cache temizleme vb. burada ateşlenir.
|
||||
├── Listeners/
|
||||
│ └── NotifySubscribersListener.php # Olayı dinleyen arka plan işlemleri
|
||||
├── Policies/
|
||||
│ └── CoursePolicy.php # Yetki ve Rol kontrolleri (Örn: Yetkisiz kişi kurs silemez)
|
||||
├── Exceptions/
|
||||
│ └── CourseFullException.php # Domain'e özel hata sınıfları (400)
|
||||
```
|
||||
|
||||
**Kusursuz (Ultimate) Veri Akış Yönü:**
|
||||
`Route` ➔ `Middleware (Auth)` ➔ `Policy (Yetki)` ➔ `FormRequest (Validasyon)` ➔ `Controller` ➔ `DTO` ➔ `Action` ➔ `Repository (Interface)` ➔ `Model` ➔ `DB`
|
||||
*(İşlem Başarılıysa)* ➔ `Action => Event Fırlatır / Activity Log Atılır`
|
||||
*(Controller Çıkışı)* ➔ `API Resource (JSON Formatlama)` ➔ `Next.js FrontEnd`
|
||||
|
||||
**Audit Log / Kullanıcı İşlem Kayıtları Notu:**
|
||||
Sisteme yetkili girişler yapılacağı için (Super Admin, Editor vs) veri güncellemelerini, silmeleri (SoftDelete edilen datalar) izlemek amacıyla projeye `spatie/laravel-activitylog` paketi kurulması, oluşan Transaction'ların tutulması önerilir.
|
||||
|
||||
---
|
||||
|
||||
## 4. Next.js Admin Panel Mimarisi Önerisi
|
||||
|
||||
Admin Paneli tamamen ayrı bir Next.js projesi olarak (Örn: `admin.bogazicidenizcilik.com` veya `localhost:3001` portunda) kurulmalıdır.
|
||||
|
||||
### **Mimari Prensip (Katmanlı Mimari - Layered Architecture):**
|
||||
Kodların tek bir klasöre yığılmasını önlemek için Frontend kodları işlevselliğe göre ayrılmalıdır. Örnek `src/` mimarisi:
|
||||
- `src/app/`: Sadece sayfalar (Routing) ve sayfa Layout'ları.
|
||||
- `src/components/`: Ortak kullanılan UI elementleri (Button, Input, Table, **Ortak ImageUpload Bileşeni**).
|
||||
- `src/features/`: Modül bazlı klasörleme (Örn: `features/courses/components`, `features/courses/api`, `features/courses/types`).
|
||||
- `src/lib/`: Axios instance, yardımcı fonksiyonlar.
|
||||
|
||||
### Önerilen Klasör Dizilimi (`src/app/` Route Alanı):
|
||||
|
||||
```text
|
||||
src/app/
|
||||
├── (auth)/
|
||||
│ └── login/page.tsx # Giriş ekranı (Kullanıcı Adı, Şifre -> Laravel Sanctum Token)
|
||||
├── (dashboard)/
|
||||
│ ├── layout.tsx # Sol Sidebar ve Üst Header kapsayıcısı (Role-based menu gösterimi)
|
||||
│ ├── page.tsx # Dashboard Özeti (İstatistik kartları: Toplam Öğrenci, Yeni Formlar)
|
||||
...
|
||||
```
|
||||
|
||||
### Tüm Admin Modülleri ve Ekran Detayları
|
||||
|
||||
#### 4.1. Kategoriler (`/categories`)
|
||||
- **`page.tsx`:** Kategori Tablosu (Resim, Kategori Adı, İşlemler (Düzenle/Sil)).
|
||||
- **`[id]/page.tsx` (Ekleme / Düzenleme Formu):**
|
||||
- `label` *(Text Input)* - Kategori Adı
|
||||
- `slug` *(Text Input)* - (Girilen isimden otomatik slug yaratılmalı)
|
||||
- `desc` *(Textarea)* - Kısa Tanıtım
|
||||
- `image` *(Upload Component)* - `POST /api/admin/uploads` tetikleyecek tek kullanımlık standart bileşen
|
||||
- **SEO Alanı:** `meta_title`, `meta_description` *(Text Input)*
|
||||
|
||||
#### 4.2. Eğitimler (`/courses`)
|
||||
- **`page.tsx`:** Eğitimler Tablosu (Kategorisine göre filtrelenebilir, Öğrenci Sayısı vs.).
|
||||
- **`[id]/page.tsx` (Ekleme / Düzenleme Formu):**
|
||||
- **Genel Bilgiler:**
|
||||
- `title`, `sub`, `category_id`, `price`, `duration`, `badge`
|
||||
- **İçerik Editörü:**
|
||||
- `desc` *(Textarea)*
|
||||
- `longDesc` *(Rich Text Editor - CKEditor/Quill/Notion-stile Editor)*
|
||||
- `image` *(Upload Component)*
|
||||
- **Dinamik Diziler (Field Arrays / Repeaters):**
|
||||
- `includes` *(Dynamic Input Array)* - "Fiyata Dahil Olanlar" (+)
|
||||
- `requirements` *(Dynamic Input Array)* - "Kayıt Şartları" (+)
|
||||
- **SEO Alanı:** `meta_title`, `meta_description` *(Text Input)*
|
||||
|
||||
#### 4.3. Eğitim Takvimi / Planlama (`/schedules`)
|
||||
- **`page.tsx`:** Takvim Tablosu (Kurs Adı, Başlangıç, Bitiş, Kontenjan).
|
||||
- **`[id]/page.tsx` (Ekleme / Düzenleme Formu):**
|
||||
- `course_id`, `start_date`, `end_date`, `location`, `quota`, `available_seats`
|
||||
- `is_urgent` *(Switch / Checkbox)* - "Son 3 Gün" uyarı ateşleyicisi
|
||||
|
||||
#### 4.4. Duyurular ve Haberler (`/announcements`)
|
||||
- **`page.tsx`:** Olayların Tablosu.
|
||||
- **`[id]/page.tsx` (Ekleme / Düzenleme Formu):**
|
||||
- `title`, `category` (Seçim ile), `excerpt`, `content` (RTE), `image` (Upload), `meta_title`, `meta_description`
|
||||
- `published_at` *(Date/Time Picker)* - İleri tarihli yayınlanma opsiyonu
|
||||
- `is_featured` *(Switch)* - "Ana Sayfada Manşet Yap" onay kutusu
|
||||
|
||||
#### 4.5. Ön Kayıt Başvuruları (CRM) (`/leads`)
|
||||
- **`page.tsx`:** Form başvuruları listesi. **`is_read = false` olan kayıtlar kalın (bold) veya fosforlu arka planla ayırt edici gösterilmelidir.**
|
||||
- **`[id]/page.tsx` (Görüntüleme Müşteri Detayı):**
|
||||
- Gelen veriler: *Adı, Telefon, Durumu, İlgilendiği Eğitim, Kaynak (Source), UTMEketleri, KVKK Onay Vrs.* (Salt Okunur - Read/Only).
|
||||
- Yalnızca Tıklandığında backend'e PATCH isteği atılıp `is_read = true` yapılır.
|
||||
- Admin işlemleri:
|
||||
- `status` *(Select)* - (Yeni, Arandı, Kayıt Oldu, İptal)
|
||||
- `notes` *(Textarea)* - Admin/Müşteri Temsilcisi iç notları. Form POST ile güncellenir.
|
||||
|
||||
#### 4.6. Menü ve Navigasyon Yönetimi (`/menus`)
|
||||
- **`page.tsx`:** Menü lokasyonlarına (`header_main`, `footer_corporate`, `footer_education`, vb.) göre sekmeli (tab) bir liste. Her lokasyon için alt alta ağaç (tree) görünümlü kayıtlar. Sürükle-bırak (Drag & Drop) ile `POST /api/admin/menus/reorder` tetiklenerek sıralama ve parent/child ilişkisi hızlıca kurulabilmeli.
|
||||
- **`[id]/page.tsx` (Ekleme / Düzenleme):**
|
||||
- `location` *(Select)* - Header Ana Menü mü Yoksa Footer mı?
|
||||
- `label` *(Text Input)* - "Eğitimler"
|
||||
- `url` *(Text Input)* - "/egitimler"
|
||||
- `type` *(Select)* - Normal Link mi (`link`), Eğitim Mega Menüsü mü (`mega_menu_education`), Takvim mi (`mega_menu_calendar`)
|
||||
- `parent_id` *(Select)* - Bir ana menünün altına eklenecekse o ana menü seçilir.
|
||||
- `is_active` *(Switch)*
|
||||
|
||||
#### 4.7. Yorum / Soru-Cevap Yönetimi (`/comments`)
|
||||
- **`page.tsx`:** Kullanıcılardan (üye olmadan) Eğitim ve Bloglara gelen tüm soruların/yorumların düştüğü ekran. `is_approved = false` olanlar (yeni gelenler) belirgin şekilde listelenir.
|
||||
- **`[id]/page.tsx` (Yorum Onay ve Yanıtlama):**
|
||||
- Gelen veriler: *Adı Soyadı, Telefon Numarası, Yorum Yapılan İçerik (Kurs Adı vs), Yorum Metni* (Read-Only).
|
||||
- `admin_reply` *(Textarea)* - Yorum/soruyu cevaplamak için adminin dolduracağı alan.
|
||||
- `is_approved` *(Switch)* - Yorumun sitede herkes tarafından gözüküp gözükmeyeceği.
|
||||
|
||||
#### 4.8. SSS / Sıkça Sorulan Sorular Yönetimi (`/faqs`)
|
||||
- **`page.tsx`:** FAQ Tablosu (Kategori, Soru, Aktif/Pasif durumu). Kategoriye göre filtrelenebilir.
|
||||
- **`[id]/page.tsx` (Ekleme / Düzenleme Formu):**
|
||||
- `category` *(Select)* - `egitimler` | `kayit` | `iletisim`
|
||||
- `question` *(Textarea)* - Soru metni
|
||||
- `answer` *(Textarea / Rich Text)* - Cevap metni
|
||||
- `order` *(Number Input)* - Kategori içi sıralama
|
||||
- `is_active` *(Switch)* - Aktif/Pasif
|
||||
|
||||
#### 4.9. Eğitim Rehberi Kartları Yönetimi (`/guide-cards`)
|
||||
- **`page.tsx`:** Rehber kartlarının listesi (Başlık, Icon, Link, Sıra).
|
||||
- **`[id]/page.tsx` (Ekleme / Düzenleme Formu):**
|
||||
- `title` *(Text Input)* - Kart başlığı (Örn: "Yeni Başlayanlar")
|
||||
- `description` *(Textarea)* - Açıklama metni
|
||||
- `icon` *(Select)* - Lucide icon seçimi (`anchor`, `compass`, `shield`, `briefcase` vb.)
|
||||
- `color` *(Text Input / Color Picker)* - Gradient renk sınıfı
|
||||
- `link` *(Text Input)* - Yönlendirme URL'i
|
||||
- `order` *(Number Input)* - Sıralama
|
||||
- `is_active` *(Switch)*
|
||||
|
||||
#### 4.10. Sitenin Genel Ayarları (`/settings`)
|
||||
Bu tek bir sayfada çalışır:
|
||||
- Sayfa ilk açıldığında `GET /api/admin/settings` ile tüm satırları çeker.
|
||||
- `group` verisine göre yatay ya da dikey Tabs (Sekmeler) oluşturur (Genel, Anasayfa, İletişim, Sosyal Medya, SEO, Formlar, Eğitim Rehberi, Danışmanlık).
|
||||
- **Anasayfa Sekmesi:** Anasayfadaki "Neden Biz", "Biz Kimiz", info/görsel alanlarındaki başlıklar (`home_about_title`), metinler (`home_about_text`) ve resimlerin (`home_about_image_1`) yönetimi.
|
||||
- **Sosyal Medya Sekmesi:** Tüm hesapların (Instagram, Facebook, Tiktok, LinkedIn vb.) linkleri buraya girilir. Frontend, boş olmayan değerleri render eder.
|
||||
- **Eğitim Rehberi Sekmesi:** Eğitim rehberi sayfasının başlık, alt metin ve CTA (Call to Action) alanlarının yönetimi.
|
||||
- **Danışmanlık Sekmesi:** Danışmanlık sayfasının başlık ve açıklama metinleri.
|
||||
- `type` verisine göre ekranda Text Input, Textarea veya Toggle Switch render eder. Kaydederken array olarak tek bir PATCH isteğinde sunucuya yollar.
|
||||
|
||||
---
|
||||
|
||||
## 5. Mevcut Frontend Projesi (Boğaziçi) Entegrasyon Adımları
|
||||
|
||||
Backend ve Admin Panel hazır olduğunda, mevcut statik yapınızda şu işlemler gerçekleştirilecektir:
|
||||
|
||||
1. **Çevresel Değişkenler:** `.env.local` içine `NEXT_PUBLIC_API_URL=http://api.bogazici.test/api/v1` eklenmeli. Yüklenen resimleri parse edebilmek için `NEXT_PUBLIC_ASSET_URL=http://api.bogazici.test` konulmalı.
|
||||
2. **Data Fetching:**
|
||||
- Statik `src/data/*.ts` dosyaları silinmeli.
|
||||
- Tüm sayfalarda `fetch` kullanılarak (veya Axios) ISR yapısıyla veriler çekilmeli.
|
||||
- *Örnek:* `const res = await fetch(\`\${process.env.NEXT_PUBLIC_API_URL}/courses\`, { next: { revalidate: 3600 } });`
|
||||
3. **Form Gönderimi:** Hero'daki form submit edildiğinde veriler `POST /api/v1/leads` ucuna JSON olarak yollanmalı.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dinamik Sayfalar İçin Headless Page Builder (`/page/` Rotası)
|
||||
|
||||
> **Kapsam Notu:** Page Builder sistemi **yalnızca** `/page/` rotası altında oluşturulan dinamik sayfalar için kullanılacaktır. Kurumsal sayfalar (`/kurumsal/hakkimizda`, `/kurumsal/vizyon-misyon`, vb.) ve `/danismanlik`, `/egitim-rehberi`, `/sss` gibi özel tasarımlı sayfalar frontend'de statik bileşenler olarak kalacak ve verilerini kendi API endpoint'lerinden veya `settings` tablosundan çekeceklerdir.
|
||||
|
||||
Admin panelden yeni landing page'ler, kampanya sayfaları veya özel içerik sayfaları oluşturmak için **Modüler Blok Sistemi** kullanılacaktır.
|
||||
|
||||
### Mevcut Frontend (Boğaziçi Denizcilik) Tarafında Okunması
|
||||
|
||||
**Örnek `src/app/page/[slug]/page.tsx` Kullanımı:**
|
||||
```tsx
|
||||
export default async function DynamicPage({ params }: { params: { slug: string } }) {
|
||||
// 1. Laravel'den sayfanın aktif bloklarını çek
|
||||
const page = await fetch(`.../api/v1/pages/${params.slug}`).then(r => r.json());
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* 2. Gelen blokları (Lego parçalarını) sırası ve tipine göre render et */}
|
||||
{page.blocks.map((block) => {
|
||||
switch (block.type) {
|
||||
case 'hero':
|
||||
return <HeroBlock key={block.id} data={block.content} />;
|
||||
|
||||
case 'stats_grid':
|
||||
return <StatsBlock key={block.id} data={block.content} />;
|
||||
|
||||
case 'text_image':
|
||||
return <StoryBlock key={block.id} data={block.content} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
> **Özet Tavsiye:** Geliştirme süresi uzasa da, bir kere kurulduğunda kodlamaya bir daha dokunmadan şirketin sayısız ve benzersiz tasarımda yeni sayfalar veya landing page'ler üretmesini sağlar. Endüstri standardı bir çözümdür.
|
||||
|
||||
---
|
||||
|
||||
## 7. SEO & Yapısal Veri (Structured Data) Standartları
|
||||
|
||||
Sitenin arama motorlarında üst sıralarda yer alabilmesi için backend API'nin aşağıdaki SEO gereksinimlerini eksiksiz karşılaması gerekmektedir.
|
||||
|
||||
### 7.1. Meta Alanları (Her Modülde Zorunlu)
|
||||
|
||||
Aşağıdaki tablolarda `meta_title` ve `meta_description` alanları mevcuttur ve **zorunlu olarak** doldurulmalıdır:
|
||||
- `categories` — ✅ `meta_title`, `meta_description`
|
||||
- `courses` — ✅ `meta_title`, `meta_description`
|
||||
- `announcements` — ✅ `meta_title`, `meta_description`
|
||||
|
||||
> **Kural:** Admin panelde bu alanlar boş bırakıldığında, `title` veya `excerpt` alanından otomatik üretilmelidir (Frontend veya Backend tarafında fallback mantığı ile).
|
||||
|
||||
### 7.2. OpenGraph Görsel Desteği
|
||||
|
||||
Sosyal medyada paylaşıldığında güzel bir önizleme kartı oluşturabilmek için:
|
||||
- `courses.image` ve `announcements.image` alanları aynı zamanda **OG Image** olarak kullanılacaktır.
|
||||
- Frontend'de `generateMetadata` fonksiyonunda bu görseller `openGraph.images` altına eklenmelidir.
|
||||
- Görseller **minimum 1200x630px** boyutunda olmalıdır. Admin panelde resim yüklerken bu uyarı gösterilmelidir.
|
||||
|
||||
### 7.3. JSON-LD Yapısal Veri Şemaları (Schema.org)
|
||||
|
||||
Google Rich Snippets, Knowledge Panel ve zengin arama sonuçları için aşağıdaki JSON-LD blokları frontend'de render edilmelidir:
|
||||
|
||||
#### Ana Sayfa (`/`)
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "EducationalOrganization",
|
||||
"name": "Boğaziçi Denizcilik",
|
||||
"url": "https://www.bogazicidenizcilik.com.tr",
|
||||
"logo": "...",
|
||||
"description": "...",
|
||||
"address": { "@type": "PostalAddress", ... },
|
||||
"contactPoint": { "@type": "ContactPoint", ... },
|
||||
"sameAs": ["instagram", "facebook", "youtube", ...]
|
||||
}
|
||||
```
|
||||
*(Mevcut frontend'de zaten var ✅ — Veriler settings API'den çekilmeli)*
|
||||
|
||||
#### Eğitim Detay Sayfası (`/egitimler/{kategori}/{slug}`)
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Course",
|
||||
"name": "STCW Temel Güvenlik (BST)",
|
||||
"description": "...",
|
||||
"provider": {
|
||||
"@type": "Organization",
|
||||
"name": "Boğaziçi Denizcilik",
|
||||
"sameAs": "https://www.bogazicidenizcilik.com.tr"
|
||||
},
|
||||
"hasCourseInstance": {
|
||||
"@type": "CourseInstance",
|
||||
"courseMode": "onsite",
|
||||
"duration": "P5D",
|
||||
"location": { "@type": "Place", "name": "Kadıköy, İstanbul" }
|
||||
}
|
||||
}
|
||||
```
|
||||
> **Backend Notu:** API'den `GET /api/v1/courses/{slug}` response'unda `json_ld` alanı opsiyonel olarak döndürülebilir veya frontend tarafında oluşturulabilir.
|
||||
|
||||
#### Duyuru Detay Sayfası (`/duyurular/{slug}`)
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "NewsArticle",
|
||||
"headline": "...",
|
||||
"datePublished": "2026-02-15",
|
||||
"author": { "@type": "Organization", "name": "Boğaziçi Denizcilik" },
|
||||
"image": "...",
|
||||
"publisher": { "@type": "Organization", "name": "Boğaziçi Denizcilik", "logo": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
#### SSS Sayfası (`/sss`) — **FAQPage Schema (Çok Önemli)**
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Eğitimleriniz hangi kurumlar tarafından onaylıdır?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Tüm eğitimlerimiz Ulaştırma ve Altyapı Bakanlığı onaylıdır..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
> ⚠️ **Google FAQPage Rich Result**: SSS sayfasında bu şema kullanılırsa arama sonuçlarında soru-cevaplar **doğrudan** gösterilir. Bu `GET /api/v1/faqs` endpoint'inden dönen verilerle frontend'de dinamik olarak oluşturulmalıdır.
|
||||
|
||||
#### Tüm Sayfalarda — **BreadcrumbList Schema**
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{ "@type": "ListItem", "position": 1, "name": "Anasayfa", "item": "https://..." },
|
||||
{ "@type": "ListItem", "position": 2, "name": "Eğitimler", "item": "https://.../egitimler" },
|
||||
{ "@type": "ListItem", "position": 3, "name": "STCW Temel Güvenlik" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4. Dinamik Sitemap (API Destekli)
|
||||
|
||||
Mevcut `sitemap.ts` statik datayla çalışmaktadır. Backend hazır olduğunda:
|
||||
- `GET /api/v1/sitemap-data` → Tüm public slug'ları, `updated_at` tarihleriyle döndüren özel endpoint. Frontend `sitemap.ts` bunu kullanarak dinamik sitemap oluşturacak.
|
||||
- Sitemap `lastModified` alanları gerçek `updated_at` tarihlerinden beslenmelidir (şu an tüm sayfalarda `new Date()` kullanılıyor).
|
||||
- Yeni kurs/duyuru/sayfa eklendiğinde sitemap otomatik olarak güncellenecektir.
|
||||
|
||||
**Önerilen endpoint response yapısı:**
|
||||
```json
|
||||
{
|
||||
"courses": [{ "slug": "stcw-temel-guvenlik", "kategori": "stcw", "updated_at": "2026-02-15" }],
|
||||
"announcements": [{ "slug": "kayitlar-basladi", "updated_at": "2026-02-10" }],
|
||||
"pages": [{ "slug": "kampanya-bahar-2026", "updated_at": "2026-02-01" }]
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5. Canonical URL'ler
|
||||
|
||||
- Tüm sayfalarda `<link rel="canonical">` bulunmalıdır. Mevcut frontend'de `alternates.canonical` zaten desteklenmekte. ✅
|
||||
- Backend API'den gelen slug'lar ile frontend URL yapısı **birebir eşleşmelidir**.
|
||||
- `courses` slug'ları: `/egitimler/{kategoriSlug}/{courseSlug}`
|
||||
- `announcements` slug'ları: `/duyurular/{slug}`
|
||||
|
||||
### 7.6. Hızlı SEO Kontrol Listesi (Checklist)
|
||||
|
||||
| Öğe | Durum | Açıklama |
|
||||
|---|---|---|
|
||||
| `<title>` tag — Her sayfada benzersiz | ✅ | `generateMetadata` ile dinamik |
|
||||
| `<meta description>` — Her sayfada benzersiz | ✅ | DB'den `meta_description` alanı |
|
||||
| `<link rel="canonical">` | ✅ | `alternates.canonical` ile |
|
||||
| OpenGraph tags (og:title, og:description, og:image) | ✅ | Dinamik sayfalarda var |
|
||||
| Twitter Card tags | ✅ | Layout'ta global tanımlı |
|
||||
| `robots.txt` | ✅ | `/api/` bloklanmış |
|
||||
| XML Sitemap | ✅ | Dinamik hale getirilecek (7.4) |
|
||||
| JSON-LD EducationalOrganization | ✅ | Ana sayfada mevcut |
|
||||
| JSON-LD Course | ⚠️ | Detay sayfasına eklenmeli |
|
||||
| JSON-LD NewsArticle | ⚠️ | Duyuru detay sayfasına eklenmeli |
|
||||
| JSON-LD FAQPage | ❌ | SSS sayfasına eklenmeli |
|
||||
| JSON-LD BreadcrumbList | ❌ | Tüm sayfalara eklenmeli |
|
||||
| `<html lang="tr">` | ✅ | Layout'ta mevcut |
|
||||
| Semantic HTML (h1, h2, article, nav) | ✅ | Tüm sayfalarda uygulanmış |
|
||||
| Image alt attributes | ✅ | Görsellerde title kullanılıyor |
|
||||
| Lazy loading images | ✅ | İlk 6 eager, geri kalan lazy |
|
||||
| `generateStaticParams` (SSG) | ✅ | Tüm dinamik rotalarda var |
|
||||
| ISR / Revalidate | ⚠️ | API entegrasyonunda `revalidate` eklenecek |
|
||||
|
||||
> **Öncelik Sırası:** FAQPage schema → BreadcrumbList schema → Course JSON-LD → NewsArticle JSON-LD → Dinamik Sitemap
|
||||
|
||||
---
|
||||
|
||||
## 8. Backend Uygulama Durumu (Implementation Status)
|
||||
|
||||
> *Bu bölüm, yukarıdaki gereksinimlere göre Laravel backend'de yapılan tüm implementasyonları belgelemektedir.*
|
||||
|
||||
### 8.1. Veritabanı & Model Katmanı
|
||||
|
||||
| Bileşen | Sayı | Durum | Detay |
|
||||
|---------|------|-------|-------|
|
||||
| Migration | 22 | ✅ | users, cache, jobs, permission_tables, activity_log (×3), personal_access_tokens, soft_deletes_users, categories, courses, course_schedules, announcements, hero_slides, leads, menus, comments, faqs, guide_cards, settings, pages, page_blocks |
|
||||
| Model | 14 | ✅ | User, Category, Course, CourseSchedule, Announcement, HeroSlide, Lead, Menu, Comment, Faq, GuideCard, Setting, Page, PageBlock |
|
||||
| Factory | 13 | ✅ | Tüm modeller için (User dahil) |
|
||||
| Enum | 8 | ✅ | AnnouncementCategory, FaqCategory, LeadSource, LeadStatus, MenuLocation, MenuType, SettingGroup, SettingType |
|
||||
| Seeder | 5 | ✅ | DatabaseSeeder, RolePermissionSeeder, AdminUserSeeder, SettingSeeder, RoleSeeder (artık kullanılmıyor) |
|
||||
|
||||
### 8.2. Katmanlı Mimari (Repository Pattern) Bileşenleri
|
||||
|
||||
Dökümanın 3. bölümünde belirtilen mimari tam olarak uygulanmıştır:
|
||||
|
||||
```
|
||||
Route ➔ Middleware (Auth) ➔ Policy (Yetki) ➔ FormRequest (Validasyon) ➔ Controller ➔ DTO ➔ Action ➔ Repository (Interface) ➔ Model ➔ DB
|
||||
```
|
||||
|
||||
| Katman | Sayı | Kapsam |
|
||||
|--------|------|--------|
|
||||
| Repository Interface | 13 | BaseRepositoryInterface + 12 modül |
|
||||
| Repository Eloquent | 13 | BaseRepository + 12 modül |
|
||||
| DTO (Data Transfer Object) | 11 | Tüm CRUD modülleri |
|
||||
| Action (Service) | 34 | Create/Update/Delete × 11 modül + UpdateSettingsAction |
|
||||
| FormRequest | 25 | Store/Update × 11 modül + LoginRequest + ReorderMenuRequest + UpdateSettingsRequest |
|
||||
| API Resource | 13 | Tüm modüller + PageBlockResource |
|
||||
| Controller | 28 | 13 Public (V1) + 15 Admin |
|
||||
| Policy | 11 | Category, Course, CourseSchedule, Announcement, HeroSlide, Lead, Menu, Comment, Faq, GuideCard, Page |
|
||||
|
||||
### 8.3. Yetkilendirme & Permission Sistemi (Spatie Permission v7)
|
||||
|
||||
#### Roller
|
||||
| Rol | Açıklama |
|
||||
|-----|----------|
|
||||
| `super-admin` | Tüm 48 permission'a sahip |
|
||||
| `editor` | Silme (`delete-*`) hariç 36 permission'a sahip |
|
||||
|
||||
#### Permission Yapısı (12 Modül × 4 Aksiyon = 48 Permission)
|
||||
|
||||
| Modül | `view-*` | `create-*` | `update-*` | `delete-*` |
|
||||
|-------|----------|------------|------------|------------|
|
||||
| category | ✅ | ✅ | ✅ | ✅ |
|
||||
| course | ✅ | ✅ | ✅ | ✅ |
|
||||
| schedule | ✅ | ✅ | ✅ | ✅ |
|
||||
| announcement | ✅ | ✅ | ✅ | ✅ |
|
||||
| hero-slide | ✅ | ✅ | ✅ | ✅ |
|
||||
| lead | ✅ | ✅ | ✅ | ✅ |
|
||||
| menu | ✅ | ✅ | ✅ | ✅ |
|
||||
| comment | ✅ | ✅ | ✅ | ✅ |
|
||||
| faq | ✅ | ✅ | ✅ | ✅ |
|
||||
| guide-card | ✅ | ✅ | ✅ | ✅ |
|
||||
| setting | ✅ | ✅ | ✅ | ✅ |
|
||||
| page | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
> **Not:** `editor` rolü `delete-*` permission'larından hariç tutulmuştur. Tüm policy dosyaları `hasPermissionTo()` ile çalışmaktadır (rol kontrolü değil, permission kontrolü).
|
||||
|
||||
### 8.4. API Rotaları
|
||||
|
||||
Toplam **97 rota** kayıtlıdır.
|
||||
|
||||
#### Public API — `GET /api/v1/...`
|
||||
|
||||
| Endpoint | Controller | Açıklama |
|
||||
|----------|-----------|----------|
|
||||
| `GET /api/v1/categories` | CategoryController@index | Kategorileri listeler |
|
||||
| `GET /api/v1/categories/{slug}` | CategoryController@show | Tekil kategori |
|
||||
| `GET /api/v1/courses` | CourseController@index | Eğitimleri listeler |
|
||||
| `GET /api/v1/courses/{slug}` | CourseController@show | Tekil eğitim detayı |
|
||||
| `GET /api/v1/schedules` | ScheduleController@index | Takvim eğitimleri |
|
||||
| `GET /api/v1/schedules/upcoming` | ScheduleController@upcoming | Yaklaşan eğitimler |
|
||||
| `GET /api/v1/schedules/{id}` | ScheduleController@show | Tekil takvim kaydı |
|
||||
| `GET /api/v1/announcements` | AnnouncementController@index | Duyurular |
|
||||
| `GET /api/v1/announcements/{slug}` | AnnouncementController@show | Tekil duyuru |
|
||||
| `GET /api/v1/hero-slides` | HeroSlideController@index | Aktif slider |
|
||||
| `POST /api/v1/leads` | LeadController@store | Ön kayıt formu (Rate Limit: 3/dk) |
|
||||
| `GET /api/v1/comments/{type}/{id}` | CommentController@index | Onaylı yorumlar |
|
||||
| `POST /api/v1/comments` | CommentController@store | Yeni yorum (Rate Limit: 3/dk) |
|
||||
| `GET /api/v1/menus/{location}` | MenuController@index | Menü ağacı |
|
||||
| `GET /api/v1/faqs` | FaqController@index | SSS listesi |
|
||||
| `GET /api/v1/faqs/{category}` | FaqController@index | Kategoriye göre SSS |
|
||||
| `GET /api/v1/guide-cards` | GuideCardController@index | Rehber kartları |
|
||||
| `GET /api/v1/settings` | SettingController@index | Public ayarlar (`is_public=true`) |
|
||||
| `GET /api/v1/pages/{slug}` | PageController@show | Dinamik sayfa (Page Builder) |
|
||||
| `GET /api/v1/sitemap-data` | SitemapController@index | Sitemap verileri |
|
||||
|
||||
#### Admin API — `POST|GET|PUT|DELETE /api/admin/...` (Sanctum Auth)
|
||||
|
||||
| Endpoint | Controller | Açıklama |
|
||||
|----------|-----------|----------|
|
||||
| `POST /api/admin/login` | AuthController@login | Giriş → Bearer Token |
|
||||
| `GET /api/admin/me` | AuthController@me | Giriş yapan admin bilgisi |
|
||||
| `POST /api/admin/logout` | AuthController@logout | Çıkış |
|
||||
| `POST /api/admin/uploads` | UploadController@store | Dosya yükleme |
|
||||
| `CRUD /api/admin/categories` | AdminCategoryController | apiResource |
|
||||
| `CRUD /api/admin/courses` | AdminCourseController | apiResource |
|
||||
| `CRUD /api/admin/schedules` | AdminScheduleController | apiResource |
|
||||
| `CRUD /api/admin/announcements` | AdminAnnouncementController | apiResource |
|
||||
| `CRUD /api/admin/hero-slides` | AdminHeroSlideController | apiResource |
|
||||
| `CRUD /api/admin/leads` | AdminLeadController | apiResource (store hariç) |
|
||||
| `POST /api/admin/menus/reorder` | AdminMenuController@reorder | Menü sıralaması |
|
||||
| `CRUD /api/admin/menus` | AdminMenuController | apiResource |
|
||||
| `CRUD /api/admin/comments` | AdminCommentController | apiResource (store hariç) |
|
||||
| `CRUD /api/admin/faqs` | AdminFaqController | apiResource |
|
||||
| `CRUD /api/admin/guide-cards` | AdminGuideCardController | apiResource |
|
||||
| `GET /api/admin/settings` | AdminSettingController@index | Tüm ayarlar |
|
||||
| `GET /api/admin/settings/group/{group}` | AdminSettingController@group | Grup bazlı |
|
||||
| `PUT /api/admin/settings` | AdminSettingController@update | Toplu güncelleme |
|
||||
| `CRUD /api/admin/pages` | AdminPageController | apiResource |
|
||||
| `POST /api/admin/pages/{page}/blocks/reorder` | AdminBlockController@reorder | Blok sıralaması |
|
||||
| `CRUD /api/admin/pages/{page}/blocks` | AdminBlockController | Nested apiResource |
|
||||
|
||||
### 8.5. Cache & Event Sistemi
|
||||
|
||||
| Bileşen | Dosya | Açıklama |
|
||||
|---------|-------|----------|
|
||||
| Event | `app/Events/ModelChanged.php` | Tüm CRUD Action'larda dispatch edilir |
|
||||
| Listener | `app/Listeners/ClearModelCache.php` | ModelChanged event'ini dinler, ilgili cache key'lerini temizler |
|
||||
|
||||
**Cache Uygulanan Repository'ler:**
|
||||
|
||||
| Repository | Cache Key | TTL | Açıklama |
|
||||
|-----------|-----------|-----|----------|
|
||||
| SettingRepository | `settings:public` | 1 saat | Public ayarlar |
|
||||
| HeroSlideRepository | `hero_slides:active` | 1 saat | Aktif slider |
|
||||
| GuideCardRepository | `guide_cards:active` | 1 saat | Aktif rehber kartları |
|
||||
| MenuRepository | `menus:{location}` | 1 saat | Lokasyona göre menü |
|
||||
| FaqRepository | `faqs:{category}` | 1 saat | Kategoriye göre SSS |
|
||||
|
||||
> Admin panelden veri güncellendiğinde `ModelChanged` event'i fırlatılır ve `ClearModelCache` listener ilgili cache key'lerini otomatik olarak temizler.
|
||||
|
||||
### 8.6. Güvenlik & Middleware
|
||||
|
||||
| Özellik | Durum | Detay |
|
||||
|---------|-------|-------|
|
||||
| Sanctum Token Auth | ✅ | Admin API koruması (`auth:sanctum`) |
|
||||
| Rate Limiting | ✅ | `leads`: 3/dk, `comments`: 3/dk (IP bazlı) |
|
||||
| Global API Throttle | ✅ | 60 istek/dk (`bootstrap/app.php`) |
|
||||
| Stateful API | ✅ | SPA oturumu için |
|
||||
| CORS | ✅ | `max_age: 86400` |
|
||||
| 404 Handler | ✅ | API isteklerinde JSON hata dönüşü |
|
||||
| SoftDeletes | ✅ | users, categories, courses, leads, comments |
|
||||
| Spatie Activity Log | ✅ | `spatie/laravel-activitylog` kurulu |
|
||||
|
||||
### 8.7. Swagger / OpenAPI Dökümantasyonu
|
||||
|
||||
- **Paket:** `darkaonline/l5-swagger v10.1.0`
|
||||
- **Erişim:** `GET /api/documentation`
|
||||
- **Ana Annotation Dosyası:** `app/Http/Controllers/SwaggerAnnotations.php`
|
||||
- **Toplam Path:** 45+ endpoint, 77+ operasyon
|
||||
- Tüm controller'larda `@OA\Get`, `@OA\Post`, `@OA\Put`, `@OA\Delete` annotation'ları mevcuttur.
|
||||
|
||||
### 8.8. Teknik Altyapı
|
||||
|
||||
| Paket | Versiyon | Kullanım |
|
||||
|-------|----------|----------|
|
||||
| PHP | 8.4 | Runtime |
|
||||
| Laravel Framework | v12 | Ana framework |
|
||||
| Laravel Sanctum | v4 | Token auth |
|
||||
| Spatie Permission | v7.2 | RBAC (48 permission, 2 rol) |
|
||||
| Spatie Activity Log | v4.12 | Audit log |
|
||||
| darkaonline/l5-swagger | v10.1 | API dökümantasyonu |
|
||||
| Pest | v4 | Test framework |
|
||||
| Laravel Pint | v1 | Kod formatlama |
|
||||
| Laravel Herd | - | Yerel sunucu |
|
||||
|
||||
### 8.9. Dosya Özeti
|
||||
|
||||
| Kategori | Sayı |
|
||||
|----------|------|
|
||||
| Migration | 22 |
|
||||
| Model | 14 |
|
||||
| Enum | 8 |
|
||||
| Controller | 28 (+2 base/swagger) |
|
||||
| FormRequest | 25 |
|
||||
| API Resource | 13 |
|
||||
| Repository Interface | 13 |
|
||||
| Repository Eloquent | 13 |
|
||||
| Action | 34 |
|
||||
| DTO | 11 |
|
||||
| Policy | 11 |
|
||||
| Event | 1 |
|
||||
| Listener | 1 |
|
||||
| Seeder | 5 |
|
||||
| Factory | 13 |
|
||||
| **Toplam** | **~214 dosya** |
|
||||
|
||||
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
7
cz.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
commitizen:
|
||||
name: cz_conventional_commits
|
||||
tag_format: v$version
|
||||
update_changelog_on_bump: true
|
||||
version_provider: npm
|
||||
version_scheme: semver
|
||||
11
docker-compose.prod.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
bdc-admin-prod:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: https://api.bogazicidenizcilik.com
|
||||
container_name: bdc-admin-prod
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3202:80"
|
||||
11
docker-compose.test.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
bdc-admin-test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: https://api.test.bogazicidenizcilik.com
|
||||
container_name: bdc-admin-test
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3102:80"
|
||||
22
docker/nginx/default.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# No cache for index.html
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
}
|
||||
59
eslint.config.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import globals from 'globals'
|
||||
import js from '@eslint/js'
|
||||
import pluginQuery from '@tanstack/eslint-plugin-query'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default defineConfig(
|
||||
{ ignores: ['dist', 'src/components/ui'] },
|
||||
{
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...pluginQuery.configs['flat/recommended'],
|
||||
],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'no-console': 'error',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
args: 'all',
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrors: 'all',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
// Enforce type-only imports for TypeScript types
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports',
|
||||
disallowTypeAnnotations: false,
|
||||
},
|
||||
],
|
||||
// Prevent duplicate imports from the same module
|
||||
'no-duplicate-imports': 'error',
|
||||
},
|
||||
}
|
||||
)
|
||||
BIN
image2.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
image3.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
80
index.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/images/favicon.svg"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="/images/favicon_light.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/images/favicon.png"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/images/favicon_light.png"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Shadcn Admin</title>
|
||||
<meta name="title" content="Shadcn Admin" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Admin Dashboard UI built with Shadcn and Vite."
|
||||
/>
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://shadcn-admin.netlify.app" />
|
||||
<meta property="og:title" content="Shadcn Admin" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Admin Dashboard UI built with Shadcn and Vite."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://shadcn-admin.netlify.app/images/shadcn-admin.png"
|
||||
/>
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://shadcn-admin.netlify.app" />
|
||||
<meta property="twitter:title" content="Shadcn Admin" />
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Admin Dashboard UI built with Shadcn and Vite."
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image"
|
||||
content="https://shadcn-admin.netlify.app/images/shadcn-admin.png"
|
||||
/>
|
||||
|
||||
<!-- font family -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Manrope:wght@200..800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<meta name="theme-color" content="#fff" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
knip.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { KnipConfig } from 'knip';
|
||||
|
||||
const config: KnipConfig = {
|
||||
ignore: ['src/components/ui/**', 'src/routeTree.gen.ts'],
|
||||
ignoreDependencies: ["tailwindcss", "tw-animate-css"]
|
||||
};
|
||||
|
||||
export default config;
|
||||
4
netlify.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
9013
package-lock.json
generated
Normal file
88
package.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "shadcn-admin",
|
||||
"private": false,
|
||||
"version": "2.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"knip": "knip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/clerk-react": "^5.58.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-direction": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-router": "^1.141.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-underline": "^3.20.1",
|
||||
"@tiptap/react": "^3.20.1",
|
||||
"@tiptap/starter-kit": "^3.20.1",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.561.0",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "9.12.0",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.68.0",
|
||||
"react-top-loading-bar": "^3.0.2",
|
||||
"recharts": "^3.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.2.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@faker-js/faker": "^10.1.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.91.2",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@tanstack/react-router-devtools": "^1.141.2",
|
||||
"@tanstack/router-plugin": "^1.141.2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
|
||||
"@types/node": "^25.0.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.25",
|
||||
"globals": "^16.5.0",
|
||||
"knip": "^5.73.4",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.49.0",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
6658
pnpm-lock.yaml
generated
Normal file
254
prompts/settings-seeder.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Laravel Settings Seeder — Admin Panel Ayarları
|
||||
|
||||
`settings` tablosundaki mevcut seeder'ı tamamen yeniden yaz. Admin panel 9 grup ve toplam 105 ayar bekliyor. Her ayarın `group`, `key`, `type`, `label`, `order_index` ve `is_public` alanları olmalı.
|
||||
|
||||
---
|
||||
|
||||
## Tablo Yapısı
|
||||
|
||||
```
|
||||
settings: id, group, key, value, type, label, order_index, is_public, created_at, updated_at
|
||||
```
|
||||
|
||||
## Migration Güncelleme
|
||||
|
||||
Mevcut `settings` tablosunda `label` ve `order_index` yoksa migration ekle:
|
||||
|
||||
```
|
||||
label — string, nullable
|
||||
order_index — integer, default 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kurallar
|
||||
|
||||
- `key` benzersiz (unique) olmalı
|
||||
- `value` başlangıçta boş string veya makul varsayılan değer
|
||||
- `order_index` her grup içinde 0'dan başlar, grup içi sıralamayı belirler
|
||||
- `is_public` — public API'den (`GET /api/v1/settings`) görünmesi gerekmeyen hassas veriler `false`
|
||||
- Seeder `updateOrCreate` ile çalışsın (`key` bazlı) — tekrar çalışınca mevcut value'ları silmesin, sadece label/type/group/order_index güncellensin
|
||||
- `type` değerleri: `text`, `textarea`, `image`, `boolean`, `url`, `color`, `richtext`, `json`
|
||||
|
||||
---
|
||||
|
||||
## Tüm Ayarlar (9 grup, 105 alan)
|
||||
|
||||
### general — Genel (13 alan)
|
||||
|
||||
| order | key | type | label | is_public | value (varsayılan) |
|
||||
|-------|-----|------|-------|-----------|-------------------|
|
||||
| 0 | site_name | text | Site Adı | true | Boğaziçi Denizcilik Eğitim Kurumu |
|
||||
| 1 | site_tagline | text | Slogan | true | |
|
||||
| 2 | site_description | textarea | Kısa Site Açıklaması | true | |
|
||||
| 3 | logo_light | image | Logo — Açık Tema (beyaz navbar) | true | |
|
||||
| 4 | logo_dark | image | Logo — Koyu Tema (dark bg) | true | |
|
||||
| 5 | favicon | image | Favicon (32x32 PNG) | true | |
|
||||
| 6 | apple_touch_icon | image | Apple Touch Icon (180x180) | true | |
|
||||
| 7 | announcement_bar_active | boolean | Üst Bar Aktif mi | true | false |
|
||||
| 8 | announcement_bar_text | text | Üst Bar Metni | true | |
|
||||
| 9 | announcement_bar_url | url | Üst Bar Linki | true | |
|
||||
| 10 | announcement_bar_bg_color | color | Üst Bar Arka Plan Rengi | true | #1a3e74 |
|
||||
| 11 | maintenance_mode | boolean | Bakım Modu | true | false |
|
||||
| 12 | maintenance_message | textarea | Bakım Modu Mesajı | true | |
|
||||
|
||||
### contact — İletişim (15 alan)
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 0 | phone_primary | text | Ana Telefon | true | |
|
||||
| 1 | phone_secondary | text | İkinci Telefon | true | |
|
||||
| 2 | email_info | text | Bilgi E-postası | true | |
|
||||
| 3 | email_support | text | Destek E-postası | true | |
|
||||
| 4 | email_kayit | text | Kayıt E-postası | true | |
|
||||
| 5 | address_full | textarea | Tam Adres | true | |
|
||||
| 6 | address_short | text | Kısa Adres (navbar için) | true | |
|
||||
| 7 | district | text | İlçe | true | |
|
||||
| 8 | city | text | Şehir | true | |
|
||||
| 9 | postal_code | text | Posta Kodu | true | |
|
||||
| 10 | working_hours_weekday | text | Hafta İçi Saatleri | true | |
|
||||
| 11 | working_hours_saturday | text | Cumartesi Saatleri | true | |
|
||||
| 12 | working_hours_sunday | text | Pazar Saatleri | true | |
|
||||
| 13 | whatsapp_number | text | WhatsApp Numarası (+90 ile) | true | |
|
||||
| 14 | whatsapp_message | text | WhatsApp Varsayılan Mesaj | true | |
|
||||
|
||||
### maps — Harita (6 alan)
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 0 | google_maps_embed_url | textarea | Google Maps Embed URL (iframe src) | true | |
|
||||
| 1 | google_maps_place_url | url | Google Maps Profil Linki | true | |
|
||||
| 2 | google_maps_api_key | text | Google Maps API Key | false | |
|
||||
| 3 | latitude | text | Enlem | true | |
|
||||
| 4 | longitude | text | Boylam | true | |
|
||||
| 5 | map_zoom_level | text | Harita Zoom (1-20) | true | 15 |
|
||||
|
||||
### social — Sosyal Medya (11 alan)
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 0 | instagram_url | url | Instagram Profil URL | true | |
|
||||
| 1 | instagram_handle | text | Instagram Kullanıcı Adı (@siz) | true | |
|
||||
| 2 | facebook_url | url | Facebook Sayfası URL | true | |
|
||||
| 3 | facebook_page_id | text | Facebook Page ID | false | |
|
||||
| 4 | twitter_url | url | X (Twitter) Profil URL | true | |
|
||||
| 5 | twitter_handle | text | X Kullanıcı Adı (@siz) | true | |
|
||||
| 6 | youtube_url | url | YouTube Kanal URL | true | |
|
||||
| 7 | youtube_channel_id | text | YouTube Channel ID | false | |
|
||||
| 8 | linkedin_url | url | LinkedIn Sayfa URL | true | |
|
||||
| 9 | tiktok_url | url | TikTok Profil URL | true | |
|
||||
| 10 | pinterest_url | url | Pinterest URL | true | |
|
||||
|
||||
### seo — SEO (23 alan)
|
||||
|
||||
**Temel SEO:**
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 0 | meta_title_suffix | text | Title Eki | true | Boğaziçi Denizcilik |
|
||||
| 1 | meta_title_separator | text | Ayraç Karakteri | true | \| |
|
||||
| 2 | default_meta_description | textarea | Varsayılan Meta Açıklama | true | |
|
||||
| 3 | default_meta_keywords | textarea | Varsayılan Keywords (virgülle) | true | |
|
||||
| 4 | robots | text | Robots | true | index, follow |
|
||||
| 5 | canonical_domain | url | Canonical Domain | true | |
|
||||
|
||||
**Open Graph:**
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 6 | og_title | text | OG Default Title | true | |
|
||||
| 7 | og_description | textarea | OG Default Description | true | |
|
||||
| 8 | og_image | image | OG Default Görsel (1200x630 px) | true | |
|
||||
| 9 | og_type | text | OG Type | true | website |
|
||||
| 10 | og_locale | text | OG Locale | true | tr_TR |
|
||||
| 11 | og_site_name | text | OG Site Name | true | |
|
||||
| 12 | facebook_app_id | text | Facebook App ID | false | |
|
||||
|
||||
**Twitter / X Card:**
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 13 | twitter_card_type | text | Card Tipi | true | summary_large_image |
|
||||
| 14 | twitter_site | text | Site @handle | true | |
|
||||
| 15 | twitter_creator | text | İçerik Sahibi @handle | true | |
|
||||
| 16 | twitter_title | text | Twitter Default Title | true | |
|
||||
| 17 | twitter_description | textarea | Twitter Default Description | true | |
|
||||
| 18 | twitter_image | image | Twitter Card Görseli (1200x600 px) | true | |
|
||||
|
||||
**Doğrulama Kodları:**
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 19 | google_site_verification | text | Google Search Console Kodu | true | |
|
||||
| 20 | bing_site_verification | text | Bing Webmaster Kodu | true | |
|
||||
| 21 | yandex_verification | text | Yandex Webmaster Kodu | true | |
|
||||
| 22 | pinterest_verification | text | Pinterest Doğrulama Kodu | true | |
|
||||
|
||||
### analytics — Analitik (10 alan)
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 0 | google_analytics_id | text | Google Analytics 4 ID (G-XXXXXXXX) | false | |
|
||||
| 1 | google_tag_manager_id | text | Google Tag Manager ID (GTM-XXXXXXX) | false | |
|
||||
| 2 | google_ads_id | text | Google Ads Conversion ID | false | |
|
||||
| 3 | facebook_pixel_id | text | Meta (Facebook) Pixel ID | false | |
|
||||
| 4 | hotjar_id | text | Hotjar Site ID | false | |
|
||||
| 5 | clarity_id | text | Microsoft Clarity ID | false | |
|
||||
| 6 | tiktok_pixel_id | text | TikTok Pixel ID | false | |
|
||||
| 7 | crisp_website_id | text | Crisp Chat Website ID | false | |
|
||||
| 8 | custom_head_scripts | textarea | `<head>` içine özel script | false | |
|
||||
| 9 | custom_body_scripts | textarea | `<body>` sonuna özel script | false | |
|
||||
|
||||
### header — Header (9 alan)
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 0 | navbar_style_default | text | Varsayılan Navbar Stili (transparent/white) | true | transparent |
|
||||
| 1 | cta_button_text | text | Sağ Üst Buton Metni | true | Başvuru Yap |
|
||||
| 2 | cta_button_url | url | Sağ Üst Buton Linki | true | /kayit |
|
||||
| 3 | cta_button_color | color | Sağ Üst Buton Rengi | true | #1a3e74 |
|
||||
| 4 | show_phone_topbar | boolean | Üst Bar'da Telefon Göster | true | true |
|
||||
| 5 | show_email_topbar | boolean | Üst Bar'da E-posta Göster | true | true |
|
||||
| 6 | show_address_topbar | boolean | Üst Bar'da Adres Göster | true | true |
|
||||
| 7 | show_hours_topbar | boolean | Üst Bar'da Saat Göster | true | true |
|
||||
| 8 | show_social_navbar | boolean | Navbar'da Sosyal Medya İkonları Göster | true | true |
|
||||
|
||||
### footer — Footer (8 alan)
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 0 | footer_description | textarea | Footer Açıklaması | true | |
|
||||
| 1 | footer_logo | image | Footer Logo (varsa ayrı) | true | |
|
||||
| 2 | copyright_text | text | Copyright Metni | true | © 2026 Boğaziçi Denizcilik |
|
||||
| 3 | footer_address | textarea | Footer Adres | true | |
|
||||
| 4 | footer_phone | text | Footer Telefon | true | |
|
||||
| 5 | footer_email | text | Footer E-posta | true | |
|
||||
| 6 | footer_bg_color | color | Footer Arka Plan Rengi | true | #0f2847 |
|
||||
| 7 | show_social_footer | boolean | Footer'da Sosyal Medya Göster | true | true |
|
||||
|
||||
### integrations — Entegrasyonlar (10 alan)
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 0 | recaptcha_site_key | text | reCAPTCHA v3 Site Key | false | |
|
||||
| 1 | recaptcha_secret_key | text | reCAPTCHA v3 Secret Key | false | |
|
||||
| 2 | smtp_host | text | SMTP Host | false | |
|
||||
| 3 | smtp_port | text | SMTP Port | false | 587 |
|
||||
| 4 | smtp_username | text | SMTP Kullanıcı Adı | false | |
|
||||
| 5 | smtp_password | text | SMTP Şifre | false | |
|
||||
| 6 | smtp_encryption | text | SMTP Şifreleme (tls/ssl) | false | tls |
|
||||
| 7 | smtp_from_name | text | Mail Gönderen Adı | false | |
|
||||
| 8 | smtp_from_email | text | Mail Gönderen Adresi | false | |
|
||||
| 9 | notification_emails | textarea | Bildirim E-postaları (virgülle) | false | |
|
||||
|
||||
---
|
||||
|
||||
## is_public Özet Tablosu
|
||||
|
||||
| Grup | Tümü public? | Gizli olanlar |
|
||||
|------|-------------|---------------|
|
||||
| general | Evet | — |
|
||||
| contact | Evet | — |
|
||||
| maps | Hayır | `google_maps_api_key` |
|
||||
| social | Hayır | `facebook_page_id`, `youtube_channel_id` |
|
||||
| seo | Hayır | `facebook_app_id` |
|
||||
| analytics | Tümü gizli | Hepsi `is_public: false` |
|
||||
| header | Evet | — |
|
||||
| footer | Evet | — |
|
||||
| integrations | Tümü gizli | Hepsi `is_public: false` |
|
||||
| info_sections | Evet | — |
|
||||
|
||||
---
|
||||
|
||||
### info_sections — Tanıtım Bölümleri (10 alan)
|
||||
|
||||
**Bölüm 1:**
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 0 | info_section_1_badge | text | Bölüm 1 — Etiket | true | |
|
||||
| 1 | info_section_1_title | text | Bölüm 1 — Başlık | true | |
|
||||
| 2 | info_section_1_body | textarea | Bölüm 1 — İçerik | true | |
|
||||
| 3 | info_section_1_quote | text | Bölüm 1 — Alıntı | true | |
|
||||
| 4 | info_section_1_quote_author | text | Bölüm 1 — Alıntı Yazarı | true | |
|
||||
| 5 | info_section_1_image | image | Bölüm 1 — Görsel | true | |
|
||||
|
||||
**Bölüm 2:**
|
||||
|
||||
| order | key | type | label | is_public | value |
|
||||
|-------|-----|------|-------|-----------|-------|
|
||||
| 6 | info_section_2_badge | text | Bölüm 2 — Etiket | true | |
|
||||
| 7 | info_section_2_title | text | Bölüm 2 — Başlık | true | |
|
||||
| 8 | info_section_2_body | textarea | Bölüm 2 — İçerik | true | |
|
||||
| 9 | info_section_2_image | image | Bölüm 2 — Görsel | true | |
|
||||
|
||||
---
|
||||
|
||||
## Önemli Notlar
|
||||
|
||||
- `updateOrCreate` kullan, `key` bazlı — seeder tekrar çalıştığında adminin girdiği value'lar korunsun
|
||||
- `updateOrCreate`'de sadece şu alanları güncelle: `group`, `type`, `label`, `order_index`, `is_public`
|
||||
- `value` alanını sadece kayıt yeni oluşturuluyorsa set et (mevcut veriyi ezme)
|
||||
- Public API (`GET /api/v1/settings`) sadece `is_public: true` olanları döndürmeli
|
||||
- Admin API (`GET /api/admin/settings`) tüm ayarları döndürmeli
|
||||
- Toplam: 10 grup, 115 ayar satırı
|
||||
54
public/images/BOGAZICI-DENIZCILIK-LOGO-06.svg
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 920.88 490.62">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="cls-1" d="M188.08,242.83c0-46.08-32.97-84.45-76.54-92.67-9.1-1.72-17.5,5.4-17.5,14.66v29.85c26.52,0,48.03,21.56,48.03,48.16s-21.5,48.16-48.03,48.16-48.03-21.56-48.03-48.16h0V97.49c0-25.41-20.6-46.02-46.02-46.02h0v191.35h0c0,52.09,42.1,94.31,94.04,94.31s94.04-42.22,94.04-94.3Z"/>
|
||||
<path class="cls-1" d="M354.95,219.92h-71.45c-6.1-51.24-32.38-96.26-70.66-126.83,0,0,0,0,0,0l39.54-57.86c4.69-7.36,4.9-16.7.04-24.75-1.7-2.83-4.22-5.08-6.99-6.88h0s0-.01,0-.01c-10.61-6.79-24.76-3.66-31.54,6.98l-39.94,58.45h0c-24.31-11.25-51.37-17.54-79.91-17.54v.91c0,26.55,17.59,49.39,42.85,57.58,55.92,18.12,96.37,70.76,96.37,132.87s-40.45,114.75-96.37,132.87c-25.26,8.18-42.85,31.03-42.85,57.58v.91c27.53,0,53.69-5.86,77.33-16.37,0,0,0,0-.01,0l42.53,62.23c6.78,10.64,20.92,13.77,31.54,6.98h0s0-.01,0-.01c2.75-1.79,5.26-4.03,6.96-6.83,4.89-8.06,4.69-17.42-.01-24.79l-41.74-61.09s0,0,0,0c39.47-30.6,66.65-76.36,72.86-128.59,0,0,0,.01,0,.02h72.51c13.35,0,24.07-11.54,22.73-25.2-1.16-11.9-11.85-20.61-23.8-20.61Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="cls-1" d="M465.82,270.69c0,12.27-10.36,19.42-29.78,19.42h-39.64v-70.42h37.63c19.42,0,28.47,7.75,28.47,18.41,0,6.54-3.32,11.97-9.76,15.19,8.25,3.02,13.08,9.05,13.08,17.4ZM419.74,236.28v10.46h11.07c5.33,0,7.75-1.81,7.75-5.23s-2.41-5.23-7.75-5.23h-11.07ZM441.88,267.97c0-3.62-2.62-5.53-7.85-5.53h-14.28v11.07h14.28c5.23,0,7.85-1.91,7.85-5.53Z"/>
|
||||
<path class="cls-1" d="M471.36,254.89c0-21.33,16.8-36.82,39.74-36.82s39.74,15.49,39.74,36.82-16.8,36.82-39.74,36.82-39.74-15.49-39.74-36.82ZM526.89,254.89c0-10.97-7.04-17.6-15.79-17.6s-15.79,6.64-15.79,17.6,7.04,17.61,15.79,17.61,15.79-6.64,15.79-17.61Z"/>
|
||||
<path class="cls-1" d="M606.07,252.98h20.62v29.78c-8.55,5.84-20.32,8.95-30.68,8.95-22.74,0-39.34-15.19-39.34-36.82s16.6-36.82,39.84-36.82c13.88,0,25.05,4.83,32.19,13.58l-15.19,13.38c-4.43-5.13-9.56-7.75-15.79-7.75-10.46,0-17.1,6.74-17.1,17.6s6.64,17.61,16.8,17.61c2.92,0,5.74-.5,8.65-1.71v-17.81ZM575.39,197.75h13.58c.3,3.72,3.32,6.14,7.44,6.14s7.14-2.42,7.44-6.14h13.58c-.5,10.56-8.95,17.1-21.02,17.1s-20.52-6.54-21.03-17.1Z"/>
|
||||
<path class="cls-1" d="M684.85,277.83h-26.76l-4.73,12.27h-24.14l30.78-70.42h23.34l30.78,70.42h-24.55l-4.73-12.27ZM678.31,260.73l-6.84-17.71-6.84,17.71h13.68Z"/>
|
||||
<path class="cls-1" d="M780.13,271.69v18.41h-64.69v-14.59l32.19-37.42h-31.19v-18.41h62.07v14.59l-32.19,37.42h33.8Z"/>
|
||||
<path class="cls-1" d="M787.07,219.68h23.74v70.42h-23.74v-70.42ZM787.48,203.48c0-6.74,4.73-11.47,11.47-11.47s11.47,4.73,11.47,11.47-4.73,11.47-11.47,11.47-11.47-4.73-11.47-11.47Z"/>
|
||||
<path class="cls-1" d="M875.3,264.25l14.99,13.38c-6.54,8.45-16.5,13.48-29.07,13.98l-.8,3.72c6.34.8,8.55,4.53,8.55,8.05,0,6.74-6.84,10.86-17.1,10.86-3.42,0-7.65-.91-9.86-2.11l2.82-8.35c1.51.6,3.62,1.21,6.04,1.21,3.42,0,4.83-1.01,4.83-2.42,0-1.21-1.21-2.01-4.12-2.01h-4.12l2.31-9.66c-17.91-3.52-30.28-17.4-30.28-36.02,0-21.63,16.6-36.82,39.23-36.82,13.78,0,24.65,5.03,31.59,14.08l-14.99,13.38c-4.12-5.23-9.05-8.25-15.39-8.25-9.86,0-16.5,6.84-16.5,17.6s6.64,17.61,16.5,17.61c6.34,0,11.27-3.02,15.39-8.25Z"/>
|
||||
<path class="cls-1" d="M897.14,219.68h23.74v70.42h-23.74v-70.42ZM897.54,203.48c0-6.74,4.73-11.47,11.47-11.47s11.47,4.73,11.47,11.47-4.73,11.47-11.47,11.47-11.47-4.73-11.47-11.47Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-1" d="M396.4,339.77h26.6c20.29,0,34.08,12.8,34.08,31.56s-13.8,31.56-34.08,31.56h-26.6v-63.11ZM422.46,395.04c15.6,0,25.6-9.56,25.6-23.71s-10.01-23.71-25.6-23.71h-17.04v47.43h17.04Z"/>
|
||||
<path class="cls-1" d="M516.68,395.04v7.84h-45.8v-63.11h44.54v7.84h-35.52v19.39h31.65v7.66h-31.65v20.38h36.79Z"/>
|
||||
<path class="cls-1" d="M585.56,339.77v63.11h-7.39l-37.87-47.06v47.06h-9.02v-63.11h7.39l37.87,47.06v-47.06h9.02Z"/>
|
||||
<path class="cls-1" d="M603.69,327.69c0-2.98,2.34-5.32,5.32-5.32s5.32,2.34,5.32,5.32-2.34,5.32-5.32,5.32-5.32-2.26-5.32-5.32ZM604.5,339.77h9.02v63.11h-9.02v-63.11Z"/>
|
||||
<path class="cls-1" d="M679.61,395.04v7.84h-52.75v-6.13l39.67-49.14h-39.04v-7.84h50.94v6.13l-39.49,49.14h40.66Z"/>
|
||||
<path class="cls-1" d="M685.83,371.33c0-18.66,14.24-32.28,33.45-32.28,9.74,0,18.21,3.34,23.98,9.83l-5.86,5.68c-4.87-5.14-10.82-7.48-17.76-7.48-14.25,0-24.79,10.28-24.79,24.25s10.55,24.25,24.79,24.25c6.94,0,12.89-2.43,17.76-7.57l5.86,5.68c-5.77,6.49-14.24,9.92-24.07,9.92-19.11,0-33.36-13.62-33.36-32.28Z"/>
|
||||
<path class="cls-1" d="M755.34,327.69c0-2.98,2.34-5.32,5.32-5.32s5.32,2.34,5.32,5.32-2.34,5.32-5.32,5.32-5.32-2.26-5.32-5.32ZM756.16,339.77h9.02v63.11h-9.02v-63.11Z"/>
|
||||
<path class="cls-1" d="M784.11,339.77h9.02v55.27h34.17v7.84h-43.19v-63.11Z"/>
|
||||
<path class="cls-1" d="M836.85,327.69c0-2.98,2.34-5.32,5.32-5.32s5.32,2.34,5.32,5.32-2.34,5.32-5.32,5.32-5.32-2.26-5.32-5.32ZM837.66,339.77h9.02v63.11h-9.02v-63.11Z"/>
|
||||
<path class="cls-1" d="M886.17,374.84l-11.54,11.72v16.32h-9.02v-63.11h9.02v35.25l34.26-35.25h10.28l-26.96,28.4,28.67,34.71h-10.55l-24.16-28.04Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="cls-1" d="M426.68,486.34v3.91h-30.28v-42.81h29.36v3.91h-24.83v15.23h22.14v3.85h-22.14v15.9h25.75Z"/>
|
||||
<path class="cls-1" d="M468.21,468.84h4.34v16.33c-4.04,3.61-9.79,5.44-15.72,5.44-13.03,0-22.57-9.23-22.57-21.77s9.54-21.77,22.63-21.77c6.48,0,12.11,2.08,15.9,6.18l-2.81,2.87c-3.61-3.55-7.89-5.01-12.91-5.01-10.58,0-18.35,7.52-18.35,17.74s7.77,17.74,18.29,17.74c4.1,0,7.89-.92,11.19-3.3v-14.43ZM446.74,436.37h3.06c.12,3.24,2.81,5.2,6.24,5.2s6.12-1.96,6.24-5.2h3.06c-.18,4.89-4.16,7.89-9.3,7.89s-9.11-3-9.3-7.89Z"/>
|
||||
<path class="cls-1" d="M485.64,439.61c0-1.65,1.28-2.93,2.94-2.93s2.94,1.28,2.94,2.93-1.28,2.94-2.94,2.94-2.94-1.28-2.94-2.94ZM486.32,447.44h4.53v42.81h-4.53v-42.81Z"/>
|
||||
<path class="cls-1" d="M514.08,451.35h-15.05v-3.91h34.62v3.91h-15.05v38.9h-4.52v-38.9Z"/>
|
||||
<path class="cls-1" d="M541.18,439.61c0-1.65,1.28-2.93,2.94-2.93s2.94,1.28,2.94,2.93-1.28,2.94-2.94,2.94-2.94-1.28-2.94-2.94ZM541.85,447.44h4.53v42.81h-4.53v-42.81Z"/>
|
||||
<path class="cls-1" d="M605.76,447.44v42.81h-4.34v-34.25l-16.82,28.81h-2.14l-16.82-28.62v34.07h-4.34v-42.81h3.73l18.59,31.74,18.41-31.74h3.73Z"/>
|
||||
<path class="cls-1" d="M650.96,470.13l-8.75,8.87v11.25h-4.53v-42.81h4.53v25.87l25.14-25.87h5.2l-18.53,19.39,19.82,23.42h-5.38l-17.49-20.12Z"/>
|
||||
<path class="cls-1" d="M681.18,471.96v-24.53h4.53v24.34c0,10.15,4.77,14.8,13.09,14.8s13.15-4.65,13.15-14.8v-24.34h4.4v24.53c0,12.29-6.6,18.65-17.55,18.65s-17.62-6.36-17.62-18.65Z"/>
|
||||
<path class="cls-1" d="M760.2,490.25l-9.79-13.76c-1.1.12-2.2.18-3.43.18h-11.5v13.58h-4.53v-42.81h16.02c10.89,0,17.49,5.5,17.49,14.68,0,6.73-3.55,11.44-9.79,13.46l10.46,14.68h-4.95ZM759.96,462.12c0-6.85-4.53-10.76-13.09-10.76h-11.38v21.47h11.38c8.56,0,13.09-3.98,13.09-10.7Z"/>
|
||||
<path class="cls-1" d="M775.8,471.96v-24.53h4.53v24.34c0,10.15,4.77,14.8,13.09,14.8s13.15-4.65,13.15-14.8v-24.34h4.4v24.53c0,12.29-6.6,18.65-17.55,18.65s-17.62-6.36-17.62-18.65Z"/>
|
||||
<path class="cls-1" d="M870.05,447.44v42.81h-4.34v-34.25l-16.82,28.81h-2.14l-16.82-28.62v34.07h-4.34v-42.81h3.73l18.59,31.74,18.41-31.74h3.73Z"/>
|
||||
<path class="cls-1" d="M884.61,471.96v-24.53h4.53v24.34c0,10.15,4.77,14.8,13.09,14.8s13.15-4.65,13.15-14.8v-24.34h4.4v24.53c0,12.29-6.6,18.65-17.55,18.65s-17.62-6.36-17.62-18.65Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
4
public/images/bodemfavicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="89" height="115" viewBox="0 0 89 115" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M44.0585 56.8821C44.0585 46.0867 36.3365 37.1004 26.1289 35.1739C23.9967 34.7712 22.029 36.4388 22.029 38.6088V45.601C28.2427 45.601 33.2792 50.6518 33.2792 56.8821C33.2792 63.1124 28.2427 68.1633 22.029 68.1633C15.8158 68.1633 10.7793 63.1124 10.7793 56.8821V22.837C10.7793 16.8834 5.95306 12.0577 0 12.0577V56.8821C0 69.0821 9.86277 78.9724 22.029 78.9724C34.1957 78.9724 44.0585 69.0821 44.0585 56.8821Z" fill="#1D2844"/>
|
||||
<path d="M83.1467 51.5156H66.4103C64.9822 39.5127 58.8252 28.9677 49.8595 21.8053L49.8601 21.8059L59.1225 8.25281C60.2201 6.5291 60.2694 4.34139 59.1322 2.45499C58.733 1.79278 58.1429 1.26576 57.495 0.843564L57.4928 0.841842L57.4905 0.840697C55.0043 -0.750108 51.6904 -0.0162893 50.103 2.47618L40.746 16.1667L40.7472 16.1679C35.0531 13.5311 28.7133 12.0571 22.0293 12.0571V12.2696C22.0293 18.4891 26.1504 23.8406 32.0668 25.7574C45.1667 30.0028 54.6417 42.3328 54.6417 56.8821C54.6417 71.4313 45.1667 83.7614 32.0668 88.0062C26.1504 89.9235 22.0293 95.2745 22.0293 101.494V101.706C28.4779 101.706 34.6062 100.335 40.1434 97.8707L40.1405 97.8718L50.103 112.449C51.6904 114.943 55.0043 115.675 57.4905 114.085L57.4928 114.083L57.495 114.082C58.1389 113.662 58.7261 113.138 59.1248 112.481C60.2699 110.594 60.2224 108.401 59.1225 106.674L49.344 92.3639L49.3428 92.365C58.5898 85.1969 64.9564 74.4778 66.4109 62.244V62.2474H83.397C86.5242 62.2474 89.0345 59.5436 88.7223 56.3442C88.4502 53.5578 85.9468 51.5156 83.1467 51.5156Z" fill="#233D70"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
41
public/images/bodemlogo.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<svg width="209" height="109" viewBox="0 0 209 109" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_19_42)">
|
||||
<path d="M104.02 32.88C104.02 26.64 99.56 21.44 93.65 20.33C92.42 20.1 91.28 21.06 91.28 22.32V26.36C94.87 26.36 97.78 29.28 97.78 32.88C97.78 36.48 94.87 39.4 91.28 39.4C87.69 39.4 84.78 36.48 84.78 32.88V13.2C84.78 9.76003 81.99 6.97003 78.55 6.97003V32.88C78.55 39.93 84.25 45.65 91.29 45.65C98.33 45.65 104.03 39.93 104.03 32.88H104.02Z" fill="#1A2846"/>
|
||||
<path d="M126.62 29.78H116.94C116.11 22.84 112.55 16.75 107.37 12.6L112.72 4.76C113.35 3.76 113.38 2.5 112.72 1.41C112.49 1.03 112.15 0.720004 111.77 0.480004C110.33 -0.439996 108.42 -0.0199957 107.5 1.43L102.09 9.34C98.8 7.82 95.13 6.96 91.27 6.96V7.08C91.27 10.68 93.65 13.77 97.07 14.88C104.64 17.33 110.12 24.46 110.12 32.87C110.12 41.28 104.64 48.41 97.07 50.86C93.65 51.97 91.27 55.06 91.27 58.66V58.78C95 58.78 98.54 57.99 101.74 56.56L107.5 64.99C108.42 66.43 110.33 66.86 111.77 65.94C112.14 65.7 112.48 65.39 112.71 65.01C113.37 63.92 113.34 62.65 112.71 61.65L107.06 53.38C112.41 49.24 116.09 43.04 116.93 35.97H126.75C128.56 35.97 130.01 34.41 129.83 32.56C129.67 30.95 128.23 29.77 126.61 29.77L126.62 29.78Z" fill="#1A3E74"/>
|
||||
<path d="M28.52 107.42H31.41V107.98H27.92V102.03H31.36V102.59H28.51V104.7H31.15V105.26H28.51V107.42H28.52Z" fill="#1A3E74"/>
|
||||
<path d="M45.7301 105.15V105.46C45.7301 106.2 45.4701 106.82 44.9401 107.33C44.4101 107.84 43.7101 108.09 42.8201 108.09C41.9301 108.09 41.1701 107.79 40.5801 107.2C39.9901 106.6 39.7001 105.88 39.7001 105.01C39.7001 104.14 39.9901 103.42 40.5801 102.82C41.1701 102.22 41.9201 101.93 42.8201 101.93C43.3701 101.93 43.8701 102.06 44.3301 102.33C44.7901 102.6 45.1401 102.95 45.3901 103.39L44.8601 103.7C44.6701 103.34 44.3901 103.05 44.0301 102.83C43.6601 102.62 43.2601 102.51 42.8101 102.51C42.0701 102.51 41.4601 102.75 40.9901 103.23C40.5201 103.71 40.2801 104.3 40.2801 105.01C40.2801 105.72 40.5201 106.3 40.9901 106.79C41.4601 107.27 42.0701 107.51 42.8101 107.51C43.4801 107.51 44.0101 107.34 44.4201 107C44.8301 106.66 45.0501 106.23 45.1101 105.71H42.7501V105.15H45.7301ZM42.7001 101.5C42.3401 101.5 42.0501 101.4 41.8301 101.21C41.6101 101.01 41.5101 100.74 41.5101 100.4H42.0501C42.0501 100.81 42.2701 101.01 42.7001 101.01C43.1301 101.01 43.3501 100.81 43.3501 100.4H43.8901C43.8901 100.75 43.7801 101.02 43.5701 101.22C43.3601 101.41 43.0701 101.51 42.7001 101.51V101.5Z" fill="#1A3E74"/>
|
||||
<path d="M55.04 101.3C54.95 101.39 54.84 101.44 54.71 101.44C54.58 101.44 54.47 101.4 54.38 101.3C54.29 101.21 54.24 101.1 54.24 100.97C54.24 100.84 54.29 100.73 54.38 100.64C54.47 100.55 54.58 100.5 54.71 100.5C54.84 100.5 54.95 100.55 55.04 100.64C55.13 100.73 55.18 100.84 55.18 100.97C55.18 101.1 55.13 101.21 55.04 101.3ZM54.41 102.03H55.01V107.98H54.41V102.03Z" fill="#1A3E74"/>
|
||||
<path d="M67.76 102.03V102.59H65.93V107.98H65.33V102.59H63.5V102.03H67.75H67.76Z" fill="#1A3E74"/>
|
||||
<path d="M76.8901 101.3C76.8001 101.39 76.6901 101.44 76.5601 101.44C76.4301 101.44 76.3201 101.4 76.2301 101.3C76.1401 101.21 76.0901 101.1 76.0901 100.97C76.0901 100.84 76.1401 100.73 76.2301 100.64C76.3201 100.55 76.4301 100.5 76.5601 100.5C76.6901 100.5 76.8001 100.55 76.8901 100.64C76.9801 100.73 77.0301 100.84 77.0301 100.97C77.0301 101.1 76.9801 101.21 76.8901 101.3ZM76.2701 102.03H76.8701V107.98H76.2701V102.03Z" fill="#1A3E74"/>
|
||||
<path d="M91.32 102.03V107.98H90.72V102.93L88.64 106.42H88.56L86.48 102.93V107.98H85.88V102.03H86.61L88.6 105.36L90.59 102.03H91.32Z" fill="#1A3E74"/>
|
||||
<path d="M114.28 107.98H113.5L110.71 105.09V107.98H110.11V102.03H110.71V104.75L113.41 102.03H114.19L111.33 104.92L114.27 107.98H114.28Z" fill="#1A3E74"/>
|
||||
<path d="M126.17 107.5C125.76 107.89 125.23 108.08 124.57 108.08C123.91 108.08 123.38 107.89 122.97 107.5C122.56 107.11 122.36 106.6 122.36 105.95V102.02H122.96V105.95C122.96 106.43 123.1 106.8 123.39 107.08C123.67 107.36 124.07 107.5 124.58 107.5C125.09 107.5 125.49 107.36 125.77 107.08C126.05 106.8 126.2 106.43 126.2 105.95V102.02H126.8V105.95C126.8 106.6 126.6 107.11 126.19 107.5H126.17Z" fill="#1A3E74"/>
|
||||
<path d="M139.37 107.98L137.98 105.6H136.31V107.98H135.71V102.03H138.09C138.58 102.03 139 102.2 139.35 102.55C139.7 102.9 139.87 103.32 139.87 103.81C139.87 104.21 139.75 104.57 139.51 104.89C139.27 105.21 138.96 105.42 138.59 105.52L140.04 107.98H139.36H139.37ZM136.31 102.59V105.04H138.1C138.43 105.04 138.71 104.92 138.94 104.68C139.17 104.44 139.29 104.15 139.29 103.82C139.29 103.49 139.17 103.19 138.94 102.95C138.71 102.71 138.43 102.59 138.1 102.59H136.31Z" fill="#1A3E74"/>
|
||||
<path d="M152.24 107.5C151.83 107.89 151.3 108.08 150.64 108.08C149.98 108.08 149.45 107.89 149.04 107.5C148.63 107.11 148.43 106.6 148.43 105.95V102.02H149.03V105.95C149.03 106.43 149.17 106.8 149.46 107.08C149.74 107.36 150.14 107.5 150.65 107.5C151.16 107.5 151.56 107.36 151.84 107.08C152.12 106.8 152.27 106.43 152.27 105.95V102.02H152.87V105.95C152.87 106.6 152.67 107.11 152.26 107.5H152.24Z" fill="#1A3E74"/>
|
||||
<path d="M167.22 102.03V107.98H166.62V102.93L164.54 106.42H164.46L162.38 102.93V107.98H161.78V102.03H162.51L164.5 105.36L166.49 102.03H167.22Z" fill="#1A3E74"/>
|
||||
<path d="M179.96 107.5C179.55 107.89 179.02 108.08 178.36 108.08C177.7 108.08 177.17 107.89 176.76 107.5C176.35 107.11 176.15 106.6 176.15 105.95V102.02H176.75V105.95C176.75 106.43 176.89 106.8 177.18 107.08C177.46 107.36 177.86 107.5 178.37 107.5C178.88 107.5 179.28 107.36 179.56 107.08C179.84 106.8 179.99 106.43 179.99 105.95V102.02H180.59V105.95C180.59 106.6 180.39 107.11 179.98 107.5H179.96Z" fill="#1A3E74"/>
|
||||
<path d="M12.34 89.85C12.34 92.03 10.5 93.3 7.04 93.3H0V80.78H6.69C10.14 80.78 11.75 82.16 11.75 84.05C11.75 85.21 11.16 86.18 10.01 86.75C11.48 87.29 12.34 88.36 12.34 89.84V89.85ZM4.15 83.73V85.59H6.12C7.07 85.59 7.5 85.27 7.5 84.66C7.5 84.05 7.07 83.73 6.12 83.73H4.15ZM8.09 89.37C8.09 88.73 7.62 88.39 6.69 88.39H4.15V90.36H6.69C7.62 90.36 8.09 90.02 8.09 89.38V89.37Z" fill="#1A2846"/>
|
||||
<path d="M13.33 87.04C13.33 83.25 16.32 80.49 20.4 80.49C24.48 80.49 27.47 83.24 27.47 87.04C27.47 90.84 24.48 93.59 20.4 93.59C16.32 93.59 13.33 90.84 13.33 87.04ZM23.2 87.04C23.2 85.09 21.95 83.91 20.39 83.91C18.83 83.91 17.58 85.09 17.58 87.04C17.58 88.99 18.83 90.17 20.39 90.17C21.95 90.17 23.2 88.99 23.2 87.04Z" fill="#1A2846"/>
|
||||
<path d="M37.28 86.7H40.95V92C39.43 93.04 37.34 93.59 35.49 93.59C31.45 93.59 28.5 90.89 28.5 87.04C28.5 83.19 31.45 80.49 35.58 80.49C38.05 80.49 40.03 81.35 41.3 82.9L38.6 85.28C37.81 84.37 36.9 83.9 35.79 83.9C33.93 83.9 32.75 85.1 32.75 87.03C32.75 88.96 33.93 90.16 35.74 90.16C36.26 90.16 36.76 90.07 37.28 89.86V86.69V86.7ZM31.83 76.88H34.24C34.29 77.54 34.83 77.97 35.56 77.97C36.29 77.97 36.83 77.54 36.88 76.88H39.29C39.2 78.76 37.7 79.92 35.55 79.92C33.4 79.92 31.9 78.76 31.81 76.88H31.83Z" fill="#1A2846"/>
|
||||
<path d="M51.29 91.12H46.53L45.69 93.3H41.4L46.87 80.78H51.02L56.49 93.3H52.13L51.29 91.12ZM50.13 88.08L48.91 84.93L47.69 88.08H50.12H50.13Z" fill="#1A2846"/>
|
||||
<path d="M68.23 90.03V93.3H56.73V90.71L62.45 84.06H56.9V80.79H67.94V83.38L62.22 90.03H68.23Z" fill="#1A2846"/>
|
||||
<path d="M69.46 80.78H73.68V93.3H69.46V80.78ZM69.54 77.9C69.54 76.7 70.38 75.86 71.58 75.86C72.78 75.86 73.62 76.7 73.62 77.9C73.62 79.1 72.78 79.94 71.58 79.94C70.38 79.94 69.54 79.1 69.54 77.9Z" fill="#1A2846"/>
|
||||
<path d="M85.15 88.71L87.82 91.09C86.66 92.59 84.89 93.49 82.65 93.58L82.51 94.24C83.64 94.38 84.03 95.04 84.03 95.67C84.03 96.87 82.81 97.6 80.99 97.6C80.38 97.6 79.63 97.44 79.24 97.22L79.74 95.74C80.01 95.85 80.38 95.95 80.81 95.95C81.42 95.95 81.67 95.77 81.67 95.52C81.67 95.31 81.46 95.16 80.94 95.16H80.21L80.62 93.44C77.44 92.81 75.24 90.35 75.24 87.04C75.24 83.19 78.19 80.49 82.22 80.49C84.67 80.49 86.6 81.38 87.84 82.99L85.17 85.37C84.44 84.44 83.56 83.9 82.43 83.9C80.68 83.9 79.5 85.12 79.5 87.03C79.5 88.94 80.68 90.16 82.43 90.16C83.56 90.16 84.43 89.62 85.17 88.69L85.15 88.71Z" fill="#1A2846"/>
|
||||
<path d="M89.04 80.78H93.26V93.3H89.04V80.78ZM89.11 77.9C89.11 76.7 89.95 75.86 91.15 75.86C92.35 75.86 93.19 76.7 93.19 77.9C93.19 79.1 92.35 79.94 91.15 79.94C89.95 79.94 89.11 79.1 89.11 77.9Z" fill="#1A2846"/>
|
||||
<path d="M104.35 80.78H109.63C113.65 80.78 116.39 83.32 116.39 87.04C116.39 90.76 113.65 93.3 109.63 93.3H104.35V80.78ZM109.52 91.75C112.61 91.75 114.6 89.85 114.6 87.05C114.6 84.25 112.61 82.35 109.52 82.35H106.14V91.76H109.52V91.75Z" fill="#1A3E74"/>
|
||||
<path d="M128.22 91.75V93.31H119.13V80.79H127.97V82.35H120.92V86.2H127.2V87.72H120.92V91.76H128.22V91.75Z" fill="#1A3E74"/>
|
||||
<path d="M141.88 80.78V93.3H140.41L132.9 83.96V93.3H131.11V80.78H132.58L140.09 90.12V80.78H141.88Z" fill="#1A3E74"/>
|
||||
<path d="M145.48 78.39C145.48 77.8 145.95 77.33 146.54 77.33C147.13 77.33 147.6 77.8 147.6 78.39C147.6 78.98 147.13 79.45 146.54 79.45C145.95 79.45 145.48 79 145.48 78.39ZM145.64 80.78H147.43V93.3H145.64V80.78Z" fill="#1A3E74"/>
|
||||
<path d="M160.54 91.75V93.31H150.08V92.09L157.95 82.34H150.2V80.78H160.31V82L152.47 91.75H160.54Z" fill="#1A3E74"/>
|
||||
<path d="M161.78 87.04C161.78 83.34 164.61 80.64 168.42 80.64C170.35 80.64 172.03 81.3 173.18 82.59L172.02 83.72C171.05 82.7 169.87 82.24 168.5 82.24C165.67 82.24 163.58 84.28 163.58 87.05C163.58 89.82 165.67 91.86 168.5 91.86C169.88 91.86 171.06 91.38 172.02 90.36L173.18 91.49C172.04 92.78 170.35 93.46 168.4 93.46C164.61 93.46 161.78 90.76 161.78 87.06V87.04Z" fill="#1A3E74"/>
|
||||
<path d="M175.57 78.39C175.57 77.8 176.03 77.33 176.63 77.33C177.23 77.33 177.69 77.8 177.69 78.39C177.69 78.98 177.22 79.45 176.63 79.45C176.04 79.45 175.57 79 175.57 78.39ZM175.73 80.78H177.52V93.3H175.73V80.78Z" fill="#1A3E74"/>
|
||||
<path d="M181.28 80.78H183.07V91.75H189.85V93.31H181.28V80.79V80.78Z" fill="#1A3E74"/>
|
||||
<path d="M191.74 78.39C191.74 77.8 192.21 77.33 192.8 77.33C193.39 77.33 193.86 77.8 193.86 78.39C193.86 78.98 193.39 79.45 192.8 79.45C192.21 79.45 191.74 79 191.74 78.39ZM191.9 80.78H193.69V93.3H191.9V80.78Z" fill="#1A3E74"/>
|
||||
<path d="M201.53 87.74L199.24 90.07V93.31H197.45V80.79H199.24V87.78L206.04 80.79H208.08L202.73 86.42L208.42 93.31H206.33L201.54 87.75L201.53 87.74Z" fill="#1A3E74"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_19_42">
|
||||
<rect width="208.41" height="108.08" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
BIN
public/images/favicon.png
Normal file
|
After Width: | Height: | Size: 494 B |
4
public/images/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokewidth="2" strokelinecap="round" strokelinejoin="round">
|
||||
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"></path>
|
||||
</svg><style>@media (prefers-color-scheme: light) { :root { filter: contrast(1) brightness(0.8); } }
|
||||
</style></svg>
|
||||
|
After Width: | Height: | Size: 520 B |
BIN
public/images/favicon_light.png
Normal file
|
After Width: | Height: | Size: 247 B |
1
public/images/favicon_light.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="948,463.5,24,24"><defs><clipPath id="clip-1"><rect x="948" y="463.5" width="24" height="24" id="clip-1" stroke="none"/></clipPath></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g><g id="Group 1"><g clip-path="url(#clip-1)" id="Group 1"><path d="M963,469.5v12c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3h-12c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3v-12c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3h12c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3" id="Path 1" stroke="#f2f2f2"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1013 B |
BIN
public/images/shadcn-admin.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
39
public/images/white-logo.svg
Normal file
@@ -0,0 +1,39 @@
|
||||
<svg width="209" height="109" viewBox="0 0 209 109" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_28_38" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="209" height="109">
|
||||
<path d="M208.41 0H0V108.08H208.41V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_28_38)">
|
||||
<path d="M104.02 32.8799C104.02 26.6399 99.56 21.4399 93.65 20.3299C92.42 20.0999 91.28 21.0599 91.28 22.3199V26.3599C94.87 26.3599 97.78 29.2799 97.78 32.8799C97.78 36.4799 94.87 39.3999 91.28 39.3999C87.69 39.3999 84.78 36.4799 84.78 32.8799V13.1999C84.78 9.75997 81.99 6.96997 78.55 6.96997V32.8799C78.55 39.9299 84.25 45.6499 91.29 45.6499C98.33 45.6499 104.03 39.9299 104.03 32.8799H104.02Z" fill="white"/>
|
||||
<path d="M126.62 29.78H116.94C116.11 22.84 112.55 16.75 107.37 12.6L112.72 4.76C113.35 3.76 113.38 2.5 112.72 1.41C112.49 1.03 112.15 0.720004 111.77 0.480004C110.33 -0.439996 108.42 -0.0199953 107.5 1.43L102.09 9.34C98.8 7.82 95.13 6.96 91.27 6.96V7.08C91.27 10.68 93.65 13.77 97.07 14.88C104.64 17.33 110.12 24.46 110.12 32.87C110.12 41.28 104.64 48.41 97.07 50.86C93.65 51.97 91.27 55.06 91.27 58.66V58.78C95 58.78 98.54 57.99 101.74 56.56L107.5 64.99C108.42 66.43 110.33 66.86 111.77 65.94C112.14 65.7 112.48 65.39 112.71 65.01C113.37 63.92 113.34 62.65 112.71 61.65L107.06 53.38C112.41 49.24 116.09 43.04 116.93 35.97H126.75C128.56 35.97 130.01 34.41 129.83 32.56C129.67 30.95 128.23 29.77 126.61 29.77L126.62 29.78Z" fill="white"/>
|
||||
<path d="M28.52 107.42H31.41V107.98H27.92V102.03H31.36V102.59H28.51V104.7H31.15V105.26H28.51V107.42H28.52Z" fill="white"/>
|
||||
<path d="M45.7301 105.15V105.46C45.7301 106.2 45.4701 106.82 44.9401 107.33C44.4101 107.84 43.7101 108.09 42.8201 108.09C41.9301 108.09 41.1701 107.79 40.5801 107.2C39.9901 106.6 39.7001 105.88 39.7001 105.01C39.7001 104.14 39.9901 103.42 40.5801 102.82C41.1701 102.22 41.9201 101.93 42.8201 101.93C43.3701 101.93 43.8701 102.06 44.3301 102.33C44.7901 102.6 45.1401 102.95 45.3901 103.39L44.8601 103.7C44.6701 103.34 44.3901 103.05 44.0301 102.83C43.6601 102.62 43.2601 102.51 42.8101 102.51C42.0701 102.51 41.4601 102.75 40.9901 103.23C40.5201 103.71 40.2801 104.3 40.2801 105.01C40.2801 105.72 40.5201 106.3 40.9901 106.79C41.4601 107.27 42.0701 107.51 42.8101 107.51C43.4801 107.51 44.0101 107.34 44.4201 107C44.8301 106.66 45.0501 106.23 45.1101 105.71H42.7501V105.15H45.7301ZM42.7001 101.5C42.3401 101.5 42.0501 101.4 41.8301 101.21C41.6101 101.01 41.5101 100.74 41.5101 100.4H42.0501C42.0501 100.81 42.2701 101.01 42.7001 101.01C43.1301 101.01 43.3501 100.81 43.3501 100.4H43.8901C43.8901 100.75 43.7801 101.02 43.5701 101.22C43.3601 101.41 43.0701 101.51 42.7001 101.51V101.5Z" fill="white"/>
|
||||
<path d="M55.04 101.3C54.95 101.39 54.84 101.44 54.71 101.44C54.58 101.44 54.47 101.4 54.38 101.3C54.29 101.21 54.24 101.1 54.24 100.97C54.24 100.84 54.29 100.73 54.38 100.64C54.47 100.55 54.58 100.5 54.71 100.5C54.84 100.5 54.95 100.55 55.04 100.64C55.13 100.73 55.18 100.84 55.18 100.97C55.18 101.1 55.13 101.21 55.04 101.3ZM54.41 102.03H55.01V107.98H54.41V102.03Z" fill="white"/>
|
||||
<path d="M67.76 102.03V102.59H65.93V107.98H65.33V102.59H63.5V102.03H67.75H67.76Z" fill="white"/>
|
||||
<path d="M76.8901 101.3C76.8001 101.39 76.6901 101.44 76.5601 101.44C76.4301 101.44 76.3201 101.4 76.2301 101.3C76.1401 101.21 76.0901 101.1 76.0901 100.97C76.0901 100.84 76.1401 100.73 76.2301 100.64C76.3201 100.55 76.4301 100.5 76.5601 100.5C76.6901 100.5 76.8001 100.55 76.8901 100.64C76.9801 100.73 77.0301 100.84 77.0301 100.97C77.0301 101.1 76.9801 101.21 76.8901 101.3ZM76.2701 102.03H76.8701V107.98H76.2701V102.03Z" fill="white"/>
|
||||
<path d="M91.32 102.03V107.98H90.72V102.93L88.64 106.42H88.56L86.48 102.93V107.98H85.88V102.03H86.61L88.6 105.36L90.59 102.03H91.32Z" fill="white"/>
|
||||
<path d="M114.28 107.98H113.5L110.71 105.09V107.98H110.11V102.03H110.71V104.75L113.41 102.03H114.19L111.33 104.92L114.27 107.98H114.28Z" fill="white"/>
|
||||
<path d="M126.17 107.5C125.76 107.89 125.23 108.08 124.57 108.08C123.91 108.08 123.38 107.89 122.97 107.5C122.56 107.11 122.36 106.6 122.36 105.95V102.02H122.96V105.95C122.96 106.43 123.1 106.8 123.39 107.08C123.67 107.36 124.07 107.5 124.58 107.5C125.09 107.5 125.49 107.36 125.77 107.08C126.05 106.8 126.2 106.43 126.2 105.95V102.02H126.8V105.95C126.8 106.6 126.6 107.11 126.19 107.5H126.17Z" fill="white"/>
|
||||
<path d="M139.37 107.98L137.98 105.6H136.31V107.98H135.71V102.03H138.09C138.58 102.03 139 102.2 139.35 102.55C139.7 102.9 139.87 103.32 139.87 103.81C139.87 104.21 139.75 104.57 139.51 104.89C139.27 105.21 138.96 105.42 138.59 105.52L140.04 107.98H139.36H139.37ZM136.31 102.59V105.04H138.1C138.43 105.04 138.71 104.92 138.94 104.68C139.17 104.44 139.29 104.15 139.29 103.82C139.29 103.49 139.17 103.19 138.94 102.95C138.71 102.71 138.43 102.59 138.1 102.59H136.31Z" fill="white"/>
|
||||
<path d="M152.24 107.5C151.83 107.89 151.3 108.08 150.64 108.08C149.98 108.08 149.45 107.89 149.04 107.5C148.63 107.11 148.43 106.6 148.43 105.95V102.02H149.03V105.95C149.03 106.43 149.17 106.8 149.46 107.08C149.74 107.36 150.14 107.5 150.65 107.5C151.16 107.5 151.56 107.36 151.84 107.08C152.12 106.8 152.27 106.43 152.27 105.95V102.02H152.87V105.95C152.87 106.6 152.67 107.11 152.26 107.5H152.24Z" fill="white"/>
|
||||
<path d="M167.22 102.03V107.98H166.62V102.93L164.54 106.42H164.46L162.38 102.93V107.98H161.78V102.03H162.51L164.5 105.36L166.49 102.03H167.22Z" fill="white"/>
|
||||
<path d="M179.96 107.5C179.55 107.89 179.02 108.08 178.36 108.08C177.7 108.08 177.17 107.89 176.76 107.5C176.35 107.11 176.15 106.6 176.15 105.95V102.02H176.75V105.95C176.75 106.43 176.89 106.8 177.18 107.08C177.46 107.36 177.86 107.5 178.37 107.5C178.88 107.5 179.28 107.36 179.56 107.08C179.84 106.8 179.99 106.43 179.99 105.95V102.02H180.59V105.95C180.59 106.6 180.39 107.11 179.98 107.5H179.96Z" fill="white"/>
|
||||
<path d="M12.34 89.85C12.34 92.03 10.5 93.3 7.04 93.3H0V80.78H6.69C10.14 80.78 11.75 82.16 11.75 84.05C11.75 85.21 11.16 86.18 10.01 86.75C11.48 87.29 12.34 88.36 12.34 89.84V89.85ZM4.15 83.73V85.59H6.12C7.07 85.59 7.5 85.27 7.5 84.66C7.5 84.05 7.07 83.73 6.12 83.73H4.15ZM8.09 89.37C8.09 88.73 7.62 88.39 6.69 88.39H4.15V90.36H6.69C7.62 90.36 8.09 90.02 8.09 89.38V89.37Z" fill="white"/>
|
||||
<path d="M13.33 87.04C13.33 83.25 16.32 80.49 20.4 80.49C24.48 80.49 27.47 83.24 27.47 87.04C27.47 90.84 24.48 93.59 20.4 93.59C16.32 93.59 13.33 90.84 13.33 87.04ZM23.2 87.04C23.2 85.09 21.95 83.91 20.39 83.91C18.83 83.91 17.58 85.09 17.58 87.04C17.58 88.99 18.83 90.17 20.39 90.17C21.95 90.17 23.2 88.99 23.2 87.04Z" fill="white"/>
|
||||
<path d="M37.28 86.7H40.95V92C39.43 93.04 37.34 93.59 35.49 93.59C31.45 93.59 28.5 90.89 28.5 87.04C28.5 83.19 31.45 80.49 35.58 80.49C38.05 80.49 40.03 81.35 41.3 82.9L38.6 85.28C37.81 84.37 36.9 83.9 35.79 83.9C33.93 83.9 32.75 85.1 32.75 87.03C32.75 88.96 33.93 90.16 35.74 90.16C36.26 90.16 36.76 90.07 37.28 89.86V86.69V86.7ZM31.83 76.88H34.24C34.29 77.54 34.83 77.97 35.56 77.97C36.29 77.97 36.83 77.54 36.88 76.88H39.29C39.2 78.76 37.7 79.92 35.55 79.92C33.4 79.92 31.9 78.76 31.81 76.88H31.83Z" fill="white"/>
|
||||
<path d="M51.29 91.12H46.53L45.69 93.3H41.4L46.87 80.78H51.02L56.49 93.3H52.13L51.29 91.12ZM50.13 88.08L48.91 84.93L47.69 88.08H50.12H50.13Z" fill="white"/>
|
||||
<path d="M68.23 90.03V93.3H56.73V90.71L62.45 84.06H56.9V80.79H67.94V83.38L62.22 90.03H68.23Z" fill="white"/>
|
||||
<path d="M69.46 80.78H73.68V93.3H69.46V80.78ZM69.54 77.9C69.54 76.7 70.38 75.86 71.58 75.86C72.78 75.86 73.62 76.7 73.62 77.9C73.62 79.1 72.78 79.94 71.58 79.94C70.38 79.94 69.54 79.1 69.54 77.9Z" fill="white"/>
|
||||
<path d="M85.15 88.71L87.82 91.09C86.66 92.59 84.89 93.49 82.65 93.58L82.51 94.24C83.64 94.38 84.03 95.04 84.03 95.67C84.03 96.87 82.81 97.6 80.99 97.6C80.38 97.6 79.63 97.44 79.24 97.22L79.74 95.74C80.01 95.85 80.38 95.95 80.81 95.95C81.42 95.95 81.67 95.77 81.67 95.52C81.67 95.31 81.46 95.16 80.94 95.16H80.21L80.62 93.44C77.44 92.81 75.24 90.35 75.24 87.04C75.24 83.19 78.19 80.49 82.22 80.49C84.67 80.49 86.6 81.38 87.84 82.99L85.17 85.37C84.44 84.44 83.56 83.9 82.43 83.9C80.68 83.9 79.5 85.12 79.5 87.03C79.5 88.94 80.68 90.16 82.43 90.16C83.56 90.16 84.43 89.62 85.17 88.69L85.15 88.71Z" fill="white"/>
|
||||
<path d="M89.04 80.78H93.26V93.3H89.04V80.78ZM89.11 77.9C89.11 76.7 89.95 75.86 91.15 75.86C92.35 75.86 93.19 76.7 93.19 77.9C93.19 79.1 92.35 79.94 91.15 79.94C89.95 79.94 89.11 79.1 89.11 77.9Z" fill="white"/>
|
||||
<path d="M104.35 80.78H109.63C113.65 80.78 116.39 83.32 116.39 87.04C116.39 90.76 113.65 93.3 109.63 93.3H104.35V80.78ZM109.52 91.75C112.61 91.75 114.6 89.85 114.6 87.05C114.6 84.25 112.61 82.35 109.52 82.35H106.14V91.76H109.52V91.75Z" fill="white"/>
|
||||
<path d="M128.22 91.75V93.31H119.13V80.79H127.97V82.35H120.92V86.2H127.2V87.72H120.92V91.76H128.22V91.75Z" fill="white"/>
|
||||
<path d="M141.88 80.78V93.3H140.41L132.9 83.96V93.3H131.11V80.78H132.58L140.09 90.12V80.78H141.88Z" fill="white"/>
|
||||
<path d="M145.48 78.39C145.48 77.8 145.95 77.33 146.54 77.33C147.13 77.33 147.6 77.8 147.6 78.39C147.6 78.98 147.13 79.45 146.54 79.45C145.95 79.45 145.48 79 145.48 78.39ZM145.64 80.78H147.43V93.3H145.64V80.78Z" fill="white"/>
|
||||
<path d="M160.54 91.75V93.31H150.08V92.09L157.95 82.34H150.2V80.78H160.31V82L152.47 91.75H160.54Z" fill="white"/>
|
||||
<path d="M161.78 87.04C161.78 83.34 164.61 80.64 168.42 80.64C170.35 80.64 172.03 81.3 173.18 82.59L172.02 83.72C171.05 82.7 169.87 82.24 168.5 82.24C165.67 82.24 163.58 84.28 163.58 87.05C163.58 89.82 165.67 91.86 168.5 91.86C169.88 91.86 171.06 91.38 172.02 90.36L173.18 91.49C172.04 92.78 170.35 93.46 168.4 93.46C164.61 93.46 161.78 90.76 161.78 87.06V87.04Z" fill="white"/>
|
||||
<path d="M175.57 78.39C175.57 77.8 176.03 77.33 176.63 77.33C177.23 77.33 177.69 77.8 177.69 78.39C177.69 78.98 177.22 79.45 176.63 79.45C176.04 79.45 175.57 79 175.57 78.39ZM175.73 80.78H177.52V93.3H175.73V80.78Z" fill="white"/>
|
||||
<path d="M181.28 80.78H183.07V91.75H189.85V93.31H181.28V80.79V80.78Z" fill="white"/>
|
||||
<path d="M191.74 78.39C191.74 77.8 192.21 77.33 192.8 77.33C193.39 77.33 193.86 77.8 193.86 78.39C193.86 78.98 193.39 79.45 192.8 79.45C192.21 79.45 191.74 79 191.74 78.39ZM191.9 80.78H193.69V93.3H191.9V80.78Z" fill="white"/>
|
||||
<path d="M201.53 87.74L199.24 90.07V93.31H197.45V80.79H199.24V87.78L206.04 80.79H208.08L202.73 86.42L208.42 93.31H206.33L201.54 87.75L201.53 87.74Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
28
scripts/deploy-prod.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="/opt/projects/bogazici/corporate-admin/prod/admin"
|
||||
BRANCH="main"
|
||||
CONTAINER="bdc-admin-prod"
|
||||
COMPOSE_FILE="docker-compose.prod.yml"
|
||||
|
||||
echo "🚀 [Admin] Prod deploy başlatılıyor..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Git sync
|
||||
git fetch origin
|
||||
git checkout "$BRANCH"
|
||||
git reset --hard "origin/$BRANCH"
|
||||
git clean -fd
|
||||
|
||||
# Docker - stop & remove any existing container first
|
||||
docker stop "$CONTAINER" 2>/dev/null || true
|
||||
docker rm "$CONTAINER" 2>/dev/null || true
|
||||
docker compose -f "$COMPOSE_FILE" down --remove-orphans 2>/dev/null || true
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
|
||||
# Cleanup
|
||||
docker image prune -f 2>/dev/null || true
|
||||
|
||||
echo "✅ [Admin] Prod deploy tamamlandı"
|
||||
28
scripts/deploy-test.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="/opt/projects/bogazici/corporate-admin/test/admin"
|
||||
BRANCH="test"
|
||||
CONTAINER="bdc-admin-test"
|
||||
COMPOSE_FILE="docker-compose.test.yml"
|
||||
|
||||
echo "🚀 [Admin] Test deploy başlatılıyor..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Git sync
|
||||
git fetch origin
|
||||
git checkout "$BRANCH"
|
||||
git reset --hard "origin/$BRANCH"
|
||||
git clean -fd
|
||||
|
||||
# Docker - stop & remove any existing container first
|
||||
docker stop "$CONTAINER" 2>/dev/null || true
|
||||
docker rm "$CONTAINER" 2>/dev/null || true
|
||||
docker compose -f "$COMPOSE_FILE" down --remove-orphans 2>/dev/null || true
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
|
||||
# Cleanup
|
||||
docker image prune -f 2>/dev/null || true
|
||||
|
||||
echo "✅ [Admin] Test deploy tamamlandı"
|
||||
110
src/assets/custom/icon-dir.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type Direction } from '@/context/direction-provider'
|
||||
|
||||
type IconDirProps = SVGProps<SVGSVGElement> & {
|
||||
dir: Direction
|
||||
}
|
||||
|
||||
export function IconDir({ dir, className, ...props }: IconDirProps) {
|
||||
return (
|
||||
<svg
|
||||
data-name={`icon-dir-${dir}`}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
className={cn(dir === 'rtl' && 'rotate-y-180', className)}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M23.42.51h51.92c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
|
||||
opacity={0.15}
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 14.88L17.78 14.88'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 22.09L16.08 22.09'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 18.38L14.93 18.38'
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M12.06 6.14L17.78 6.14'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
|
||||
</g>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.62}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M29.41 7.4L34.67 7.4'
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={11.21}
|
||||
width={26.03}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.44}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={17.01}
|
||||
width={44.25}
|
||||
height={13.48}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.3}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={33.57}
|
||||
width={44.25}
|
||||
height={4.67}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.21}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={41.32}
|
||||
width={36.21}
|
||||
height={4.67}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.3}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
131
src/assets/custom/icon-layout-compact.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconLayoutCompact(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-layout-compact'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={5.2}
|
||||
width={4}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.66}
|
||||
strokeWidth='2px'
|
||||
d='M7.26 11.56L8.37 11.56'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.51}
|
||||
strokeWidth='2px'
|
||||
d='M7.26 14.49L8.37 14.49'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.52}
|
||||
strokeWidth='2px'
|
||||
d='M7.26 17.39L8.37 17.39'
|
||||
/>
|
||||
<circle cx={7.81} cy={7.25} r={1.16} fill='#fff' opacity={0.8} />
|
||||
</g>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.75}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M15.81 14.49L22.89 14.49'
|
||||
/>
|
||||
<rect
|
||||
x={14.93}
|
||||
y={18.39}
|
||||
width={22.19}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.5}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={14.93}
|
||||
y={5.89}
|
||||
width={59.16}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.9}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={14.93}
|
||||
y={24.22}
|
||||
width={32.68}
|
||||
height={19.95}
|
||||
rx={2.11}
|
||||
ry={2.11}
|
||||
opacity={0.4}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={59.05}
|
||||
y={38.15}
|
||||
width={2.01}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={54.78}
|
||||
y={34.99}
|
||||
width={2.01}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={63.17}
|
||||
y={32.86}
|
||||
width={2.01}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={67.54}
|
||||
y={29.17}
|
||||
width={2.01}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.66}
|
||||
/>
|
||||
</g>
|
||||
<g opacity={0.5}>
|
||||
<circle cx={62.16} cy={18.63} r={7.5} />
|
||||
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||
</g>
|
||||
<g opacity={0.74}>
|
||||
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
124
src/assets/custom/icon-layout-default.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconLayoutDefault(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='con-layout-default'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M39.22 15.99h-8.16c-.79 0-1.43-.67-1.43-1.5s.64-1.5 1.43-1.5h8.16c.79 0 1.43.67 1.43 1.5s-.64 1.5-1.43 1.5z'
|
||||
opacity={0.75}
|
||||
/>
|
||||
<rect
|
||||
x={29.63}
|
||||
y={18.39}
|
||||
width={16.72}
|
||||
height={2.73}
|
||||
rx={1.36}
|
||||
ry={1.36}
|
||||
opacity={0.5}
|
||||
/>
|
||||
<path
|
||||
d='M75.1 6.68v1.45c0 .63-.49 1.14-1.09 1.14H30.72c-.6 0-1.09-.51-1.09-1.14V6.68c0-.62.49-1.14 1.09-1.14h43.29c.6 0 1.09.52 1.09 1.14z'
|
||||
opacity={0.9}
|
||||
/>
|
||||
<rect
|
||||
x={29.63}
|
||||
y={24.22}
|
||||
width={21.8}
|
||||
height={19.95}
|
||||
rx={2.11}
|
||||
ry={2.11}
|
||||
opacity={0.4}
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={61.06}
|
||||
y={38.15}
|
||||
width={2.01}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={56.78}
|
||||
y={34.99}
|
||||
width={2.01}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={65.17}
|
||||
y={32.86}
|
||||
width={2.01}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={69.55}
|
||||
y={29.17}
|
||||
width={2.01}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.66}
|
||||
/>
|
||||
</g>
|
||||
<g opacity={0.5}>
|
||||
<circle cx={63.17} cy={18.63} r={7.5} />
|
||||
<path d='M63.17 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||
</g>
|
||||
<g opacity={0.74}>
|
||||
<path d='M64.05 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M67.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||
</g>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={5.02}
|
||||
width={19.14}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.8}
|
||||
/>
|
||||
<g stroke='#fff'>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeWidth='2px'
|
||||
d='M9.02 17.39L21.25 17.39'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeWidth='2px'
|
||||
d='M9.02 24.6L19.54 24.6'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeWidth='2px'
|
||||
d='M9.02 20.88L18.4 20.88'
|
||||
/>
|
||||
<circle cx={10.98} cy={9.91} r={2.54} fill='#fff' opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M15.53 8.65L21.25 8.65'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M15.32 11.3L20.38 11.3' />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
100
src/assets/custom/icon-layout-full.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconLayoutFull(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-layout-full'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.75}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M6.85 14.49L15.02 14.49'
|
||||
/>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={18.39}
|
||||
width={25.6}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.5}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={5.89}
|
||||
width={68.26}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.9}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={24.22}
|
||||
width={37.71}
|
||||
height={19.95}
|
||||
rx={2.11}
|
||||
ry={2.11}
|
||||
opacity={0.4}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={59.05}
|
||||
y={38.15}
|
||||
width={2.01}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={54.78}
|
||||
y={34.99}
|
||||
width={2.01}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={63.17}
|
||||
y={32.86}
|
||||
width={2.01}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={67.54}
|
||||
y={29.17}
|
||||
width={2.01}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.66}
|
||||
/>
|
||||
</g>
|
||||
<g opacity={0.5}>
|
||||
<circle cx={62.16} cy={18.63} r={7.5} />
|
||||
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||
</g>
|
||||
<g opacity={0.74}>
|
||||
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
82
src/assets/custom/icon-sidebar-floating.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconSidebarFloating(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-sidebar-floating'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
x={5.89}
|
||||
y={5.15}
|
||||
width={19.74}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.8}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeWidth='2px'
|
||||
d='M9.81 18.36L22.04 18.36'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeWidth='2px'
|
||||
d='M9.81 25.57L20.33 25.57'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeWidth='2px'
|
||||
d='M9.81 21.85L19.18 21.85'
|
||||
/>
|
||||
<circle cx={11.76} cy={10.88} r={2.54} fill='#fff' opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M16.31 9.62L22.04 9.62'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M16.1 12.27L21.16 12.27' />
|
||||
</g>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.62}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M30.59 9.62L35.85 9.62'
|
||||
/>
|
||||
<rect
|
||||
x={29.94}
|
||||
y={13.42}
|
||||
width={26.03}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.44}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={29.94}
|
||||
y={19.28}
|
||||
width={43.11}
|
||||
height={25.87}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.3}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
58
src/assets/custom/icon-sidebar-inset.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconSidebarInset(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-sidebar-inset'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
x={23.39}
|
||||
y={5.57}
|
||||
width={50.22}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.2}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.08 17.05L17.31 17.05'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.08 24.25L15.6 24.25'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.08 20.54L14.46 20.54'
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<circle cx={7.04} cy={9.57} r={2.54} opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M11.59 8.3L17.31 8.3'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M11.38 10.95L16.44 10.95' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
53
src/assets/custom/icon-sidebar-sidebar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconSidebarSidebar(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-sidebar-sidebar'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M23.42.51h51.99c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
|
||||
opacity={0.2}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 14.88L17.78 14.88'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 22.09L16.08 22.09'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 18.38L14.93 18.38'
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M12.06 6.14L17.78 6.14'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
79
src/assets/custom/icon-theme-dark.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconThemeDark(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-theme-dark'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<g fill='#1d2b3f'>
|
||||
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
|
||||
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
|
||||
</g>
|
||||
<path
|
||||
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
|
||||
fill='#0d1628'
|
||||
/>
|
||||
<circle cx={6.7} cy={7.04} r={3.54} fill='#426187' />
|
||||
<path
|
||||
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||
fill='#426187'
|
||||
/>
|
||||
<g fill='#2a62bc'>
|
||||
<rect
|
||||
x={33.36}
|
||||
y={19.73}
|
||||
width={2.75}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={16.57}
|
||||
width={2.75}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={37.16}
|
||||
y={14.44}
|
||||
width={2.75}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={41.19}
|
||||
y={10.75}
|
||||
width={2.75}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
</g>
|
||||
<circle cx={62.74} cy={16.32} r={8} fill='#2f5491' opacity={0.5} />
|
||||
<path
|
||||
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
|
||||
fill='#2f5491'
|
||||
opacity={0.74}
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={27.75}
|
||||
width={41.62}
|
||||
height={18.62}
|
||||
rx={1.69}
|
||||
ry={1.69}
|
||||
fill='#17273f'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
78
src/assets/custom/icon-theme-light.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconThemeLight(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-theme-light'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<g fill='#d9d9d9'>
|
||||
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
|
||||
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
|
||||
</g>
|
||||
<path
|
||||
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
|
||||
fill='#ecedef'
|
||||
/>
|
||||
<circle cx={6.7} cy={7.04} r={3.54} fill='#fff' />
|
||||
<path
|
||||
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||
fill='#fff'
|
||||
/>
|
||||
<g fill='#c0c4c4'>
|
||||
<rect
|
||||
x={33.36}
|
||||
y={19.73}
|
||||
width={2.75}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={16.57}
|
||||
width={2.75}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={37.16}
|
||||
y={14.44}
|
||||
width={2.75}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={41.19}
|
||||
y={10.75}
|
||||
width={2.75}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
</g>
|
||||
<circle cx={62.74} cy={16.32} r={8} fill='#fff' />
|
||||
<g fill='#d9d9d9'>
|
||||
<path d='M63.62 15.82L67 10.15c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M67.14 10.88a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95s-1.67-2.21-2.86-2.92z' />
|
||||
</g>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={27.75}
|
||||
width={41.62}
|
||||
height={18.62}
|
||||
rx={1.69}
|
||||
ry={1.69}
|
||||
fill='#fff'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
116
src/assets/custom/icon-theme-system.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconThemeSystem({
|
||||
className,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-theme-system'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
className={cn(
|
||||
'overflow-hidden rounded-[6px]',
|
||||
'fill-primary stroke-primary group-data-[state=unchecked]:fill-muted-foreground group-data-[state=unchecked]:stroke-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<path opacity={0.2} d='M0 0.03H22.88V51.17H0z' />
|
||||
<circle
|
||||
cx={6.7}
|
||||
cy={7.04}
|
||||
r={3.54}
|
||||
fill='#fff'
|
||||
opacity={0.8}
|
||||
stroke='#fff'
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<path
|
||||
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.75}
|
||||
/>
|
||||
<path
|
||||
d='M18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.72}
|
||||
/>
|
||||
<path
|
||||
d='M15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.55}
|
||||
/>
|
||||
<path
|
||||
d='M16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.67}
|
||||
/>
|
||||
<rect
|
||||
x={33.36}
|
||||
y={19.73}
|
||||
width={2.75}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.31}
|
||||
stroke='none'
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={16.57}
|
||||
width={2.75}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.4}
|
||||
stroke='none'
|
||||
/>
|
||||
<rect
|
||||
x={37.16}
|
||||
y={14.44}
|
||||
width={2.75}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.26}
|
||||
stroke='none'
|
||||
/>
|
||||
<rect
|
||||
x={41.19}
|
||||
y={10.75}
|
||||
width={2.75}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.37}
|
||||
stroke='none'
|
||||
/>
|
||||
<g>
|
||||
<circle cx={62.74} cy={16.32} r={8} opacity={0.25} />
|
||||
<path
|
||||
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
|
||||
opacity={0.45}
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={27.75}
|
||||
width={41.62}
|
||||
height={18.62}
|
||||
rx={1.69}
|
||||
ry={1.69}
|
||||
opacity={0.3}
|
||||
stroke='none'
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
28
src/assets/logo.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Logo({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
id='bogazici-admin-logo'
|
||||
viewBox='0 0 48 48'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
height='48'
|
||||
width='48'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2.4'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={cn('size-6', className)}
|
||||
{...props}
|
||||
>
|
||||
<title>Bogazici Admin</title>
|
||||
<path d='M24 6c7.5 0 13.5 6 13.5 13.5S31.5 33 24 33 10.5 27 10.5 19.5 16.5 6 24 6Z' />
|
||||
<path d='M24 12v23' />
|
||||
<path d='M17 20.5h14' />
|
||||
<path d='M18.5 27.5 24 33l5.5-5.5' />
|
||||
<path d='M14 39.5c3.4-2.3 6.8-3.5 10-3.5s6.6 1.2 10 3.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
375
src/components/block-manager/block-content-fields.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ImagePlus, X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RichTextEditor } from '@/components/rich-text-editor'
|
||||
import { apiOrigin } from '@/lib/api/client'
|
||||
import { uploadImage } from '@/lib/api/upload'
|
||||
|
||||
type FieldDef = {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'textarea' | 'richtext' | 'url' | 'number' | 'boolean' | 'image'
|
||||
placeholder?: string
|
||||
half?: boolean
|
||||
}
|
||||
|
||||
const blockFieldMap: Record<string, FieldDef[]> = {
|
||||
hero: [
|
||||
{ key: 'title', label: 'Başlık', type: 'text', placeholder: 'Hero başlığı' },
|
||||
{ key: 'subtitle', label: 'Alt başlık', type: 'text', placeholder: 'Kısa açıklama' },
|
||||
{ key: 'description', label: 'Açıklama', type: 'richtext', placeholder: 'Detaylı açıklama' },
|
||||
{ key: 'button_text', label: 'Buton metni', type: 'text', placeholder: 'Detaylı Bilgi', half: true },
|
||||
{ key: 'button_url', label: 'Buton linki', type: 'url', placeholder: '/iletisim', half: true },
|
||||
{ key: 'image', label: 'Arka plan görseli', type: 'image' },
|
||||
],
|
||||
text: [
|
||||
{ key: 'title', label: 'Başlık', type: 'text', placeholder: 'Bölüm başlığı' },
|
||||
{ key: 'body', label: 'İçerik', type: 'richtext', placeholder: 'Metin içeriği...' },
|
||||
],
|
||||
text_image: [
|
||||
{ key: 'title', label: 'Başlık', type: 'text', placeholder: 'Bölüm başlığı' },
|
||||
{ key: 'body', label: 'Metin', type: 'richtext', placeholder: 'Açıklama metni' },
|
||||
{ key: 'image', label: 'Görsel', type: 'image' },
|
||||
{ key: 'image_alt', label: 'Görsel alt metni', type: 'text', placeholder: 'Görselin açıklaması' },
|
||||
],
|
||||
stats_grid: [
|
||||
{ key: 'title', label: 'Başlık', type: 'text', placeholder: 'İstatistikler' },
|
||||
{ key: 'stat_1_value', label: 'Değer 1', type: 'text', placeholder: '500+', half: true },
|
||||
{ key: 'stat_1_label', label: 'Etiket 1', type: 'text', placeholder: 'Öğrenci', half: true },
|
||||
{ key: 'stat_2_value', label: 'Değer 2', type: 'text', placeholder: '50+', half: true },
|
||||
{ key: 'stat_2_label', label: 'Etiket 2', type: 'text', placeholder: 'Eğitim', half: true },
|
||||
{ key: 'stat_3_value', label: 'Değer 3', type: 'text', placeholder: '20+', half: true },
|
||||
{ key: 'stat_3_label', label: 'Etiket 3', type: 'text', placeholder: 'Eğitmen', half: true },
|
||||
{ key: 'stat_4_value', label: 'Değer 4', type: 'text', placeholder: '10+', half: true },
|
||||
{ key: 'stat_4_label', label: 'Etiket 4', type: 'text', placeholder: 'Yıl', half: true },
|
||||
],
|
||||
cta: [
|
||||
{ key: 'title', label: 'Başlık', type: 'text', placeholder: 'Hemen Başlayın' },
|
||||
{ key: 'description', label: 'Açıklama', type: 'textarea', placeholder: 'CTA açıklaması' },
|
||||
{ key: 'button_text', label: 'Buton metni', type: 'text', placeholder: 'Kayıt Ol', half: true },
|
||||
{ key: 'button_url', label: 'Buton linki', type: 'url', placeholder: '/kayit', half: true },
|
||||
],
|
||||
faq: [
|
||||
{ key: 'title', label: 'Başlık', type: 'text', placeholder: 'Sıkça Sorulan Sorular' },
|
||||
{ key: 'q1', label: 'Soru 1', type: 'text', placeholder: 'Soru...' },
|
||||
{ key: 'a1', label: 'Cevap 1', type: 'textarea', placeholder: 'Cevap...' },
|
||||
{ key: 'q2', label: 'Soru 2', type: 'text', placeholder: 'Soru...' },
|
||||
{ key: 'a2', label: 'Cevap 2', type: 'textarea', placeholder: 'Cevap...' },
|
||||
{ key: 'q3', label: 'Soru 3', type: 'text', placeholder: 'Soru...' },
|
||||
{ key: 'a3', label: 'Cevap 3', type: 'textarea', placeholder: 'Cevap...' },
|
||||
],
|
||||
gallery: [
|
||||
{ key: 'title', label: 'Başlık', type: 'text', placeholder: 'Galeri başlığı' },
|
||||
{ key: 'image_1', label: 'Görsel 1', type: 'image' },
|
||||
{ key: 'image_2', label: 'Görsel 2', type: 'image' },
|
||||
{ key: 'image_3', label: 'Görsel 3', type: 'image' },
|
||||
{ key: 'image_4', label: 'Görsel 4', type: 'image' },
|
||||
],
|
||||
video: [
|
||||
{ key: 'title', label: 'Başlık', type: 'text', placeholder: 'Video başlığı' },
|
||||
{ key: 'video_url', label: 'Video URL', type: 'url', placeholder: 'https://youtube.com/watch?v=...' },
|
||||
{ key: 'description', label: 'Açıklama', type: 'textarea', placeholder: 'Video açıklaması' },
|
||||
{ key: 'thumbnail', label: 'Kapak görseli', type: 'image' },
|
||||
],
|
||||
cards: [
|
||||
{ key: 'title', label: 'Bölüm başlığı', type: 'text', placeholder: 'Kartlar bölümü' },
|
||||
{ key: 'card_1_title', label: 'Kart 1 - Başlık', type: 'text', placeholder: 'Başlık', half: true },
|
||||
{ key: 'card_1_icon', label: 'Kart 1 - İkon', type: 'text', placeholder: 'anchor', half: true },
|
||||
{ key: 'card_1_text', label: 'Kart 1 - Metin', type: 'textarea', placeholder: 'Açıklama' },
|
||||
{ key: 'card_2_title', label: 'Kart 2 - Başlık', type: 'text', placeholder: 'Başlık', half: true },
|
||||
{ key: 'card_2_icon', label: 'Kart 2 - İkon', type: 'text', placeholder: 'compass', half: true },
|
||||
{ key: 'card_2_text', label: 'Kart 2 - Metin', type: 'textarea', placeholder: 'Açıklama' },
|
||||
{ key: 'card_3_title', label: 'Kart 3 - Başlık', type: 'text', placeholder: 'Başlık', half: true },
|
||||
{ key: 'card_3_icon', label: 'Kart 3 - İkon', type: 'text', placeholder: 'shield', half: true },
|
||||
{ key: 'card_3_text', label: 'Kart 3 - Metin', type: 'textarea', placeholder: 'Açıklama' },
|
||||
],
|
||||
testimonials: [
|
||||
{ key: 'title', label: 'Bölüm başlığı', type: 'text', placeholder: 'Referanslar' },
|
||||
{ key: 'ref_1_name', label: 'Referans 1 - İsim', type: 'text', placeholder: 'Ad Soyad', half: true },
|
||||
{ key: 'ref_1_role', label: 'Referans 1 - Ünvan', type: 'text', placeholder: 'Kaptan', half: true },
|
||||
{ key: 'ref_1_text', label: 'Referans 1 - Yorum', type: 'textarea', placeholder: 'Yorum' },
|
||||
{ key: 'ref_2_name', label: 'Referans 2 - İsim', type: 'text', placeholder: 'Ad Soyad', half: true },
|
||||
{ key: 'ref_2_role', label: 'Referans 2 - Ünvan', type: 'text', placeholder: 'Mühendis', half: true },
|
||||
{ key: 'ref_2_text', label: 'Referans 2 - Yorum', type: 'textarea', placeholder: 'Yorum' },
|
||||
],
|
||||
html: [
|
||||
{ key: 'html', label: 'HTML İçerik', type: 'textarea', placeholder: '<div>...</div>' },
|
||||
],
|
||||
}
|
||||
|
||||
export function getFieldsForType(type: string): FieldDef[] {
|
||||
return blockFieldMap[type] ?? [
|
||||
{ key: 'title', label: 'Başlık', type: 'text', placeholder: 'Başlık' },
|
||||
{ key: 'body', label: 'İçerik', type: 'richtext', placeholder: 'İçerik' },
|
||||
]
|
||||
}
|
||||
|
||||
export function contentToFieldValues(
|
||||
type: string,
|
||||
content: Record<string, unknown> | null
|
||||
): Record<string, string> {
|
||||
const fields = getFieldsForType(type)
|
||||
const values: Record<string, string> = {}
|
||||
for (const field of fields) {
|
||||
const raw = content?.[field.key]
|
||||
values[field.key] = raw != null ? String(raw) : ''
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export function fieldValuesToContent(
|
||||
values: Record<string, string>
|
||||
): Record<string, unknown> {
|
||||
const content: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value.trim() !== '') {
|
||||
content[key] = value
|
||||
}
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
export function getBlockPreviewText(
|
||||
type: string,
|
||||
content: Record<string, unknown> | null
|
||||
): string {
|
||||
if (!content) return 'Boş blok'
|
||||
const title = content.title ?? content.html ?? content.body ?? ''
|
||||
const str = String(title).replace(/<[^>]*>/g, '').trim()
|
||||
return str.length > 80 ? str.slice(0, 80) + '...' : str || 'Boş blok'
|
||||
}
|
||||
|
||||
function InlineImageUpload({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (path: string) => void
|
||||
label: string
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [localPreview, setLocalPreview] = useState<string | null>(null)
|
||||
|
||||
const serverPreview = value
|
||||
? value.startsWith('http')
|
||||
? value
|
||||
: `${apiOrigin}/${value.replace(/^\/+/, '').replace(/^storage\//, '')}`
|
||||
: null
|
||||
|
||||
const displayPreview = localPreview ?? serverPreview
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (localPreview) URL.revokeObjectURL(localPreview)
|
||||
}
|
||||
}, [localPreview])
|
||||
|
||||
async function handleFile(file: File) {
|
||||
const blobUrl = URL.createObjectURL(file)
|
||||
setLocalPreview(blobUrl)
|
||||
setUploading(true)
|
||||
try {
|
||||
const result = await uploadImage(file)
|
||||
onChange(result.path)
|
||||
} catch {
|
||||
setLocalPreview(null)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Label className='text-sm'>{label}</Label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='file'
|
||||
accept='image/*'
|
||||
className='hidden'
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
e.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
<div className='flex items-center gap-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className='group relative flex h-20 w-28 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-dashed border-border/70 bg-muted/20 transition hover:border-primary/40'
|
||||
>
|
||||
{displayPreview ? (
|
||||
<img
|
||||
src={displayPreview}
|
||||
alt={label}
|
||||
className='h-full w-full object-cover'
|
||||
/>
|
||||
) : (
|
||||
<ImagePlus className='size-5 text-muted-foreground transition group-hover:text-foreground' />
|
||||
)}
|
||||
{uploading && (
|
||||
<div className='absolute inset-0 flex items-center justify-center bg-background/70'>
|
||||
<div className='size-4 animate-spin rounded-full border-2 border-primary border-t-transparent' />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className='min-w-0 flex-1'>
|
||||
{uploading ? (
|
||||
<p className='text-xs text-amber-600'>Yükleniyor...</p>
|
||||
) : value ? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='truncate text-xs text-muted-foreground'>{value}</p>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-6 shrink-0'
|
||||
onClick={() => {
|
||||
onChange('')
|
||||
setLocalPreview(null)
|
||||
}}
|
||||
>
|
||||
<X className='size-3' />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
Görsel yüklemek için tıklayın
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BlockContentFields({
|
||||
type,
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
type: string
|
||||
values: Record<string, string>
|
||||
onChange: (values: Record<string, string>) => void
|
||||
}) {
|
||||
const fields = getFieldsForType(type)
|
||||
|
||||
function updateField(key: string, value: string) {
|
||||
onChange({ ...values, [key]: value })
|
||||
}
|
||||
|
||||
const grouped: (FieldDef | FieldDef[])[] = []
|
||||
let i = 0
|
||||
while (i < fields.length) {
|
||||
const field = fields[i]
|
||||
if (field.half && i + 1 < fields.length && fields[i + 1].half) {
|
||||
grouped.push([field, fields[i + 1]])
|
||||
i += 2
|
||||
} else {
|
||||
grouped.push(field)
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{grouped.map((item) => {
|
||||
if (Array.isArray(item)) {
|
||||
return (
|
||||
<div key={item[0].key} className='grid gap-3 sm:grid-cols-2'>
|
||||
{item.map((f) => (
|
||||
<FieldRenderer
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(v) => updateField(f.key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<FieldRenderer
|
||||
key={item.key}
|
||||
field={item}
|
||||
value={values[item.key] ?? ''}
|
||||
onChange={(v) => updateField(item.key, v)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldRenderer({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: FieldDef
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
switch (field.type) {
|
||||
case 'richtext':
|
||||
return (
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-sm'>{field.label}</Label>
|
||||
<RichTextEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 'image':
|
||||
return (
|
||||
<InlineImageUpload
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={field.label}
|
||||
/>
|
||||
)
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-sm'>{field.label}</Label>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className='flex items-center justify-between rounded-xl border border-border/70 px-3 py-2'>
|
||||
<Label className='text-sm'>{field.label}</Label>
|
||||
<Switch
|
||||
checked={value === 'true'}
|
||||
onCheckedChange={(checked) => onChange(String(checked))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-sm'>{field.label}</Label>
|
||||
<Input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
17
src/components/block-manager/block-helpers.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const blockTypes = [
|
||||
{ value: 'hero', label: 'Hero Banner' },
|
||||
{ value: 'text', label: 'Metin Bloğu' },
|
||||
{ value: 'text_image', label: 'Metin + Görsel' },
|
||||
{ value: 'stats_grid', label: 'İstatistik Grid' },
|
||||
{ value: 'cta', label: 'CTA (Aksiyon Çağrısı)' },
|
||||
{ value: 'faq', label: 'SSS Bloğu' },
|
||||
{ value: 'gallery', label: 'Galeri' },
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'cards', label: 'Kart Grid' },
|
||||
{ value: 'testimonials', label: 'Referanslar' },
|
||||
{ value: 'html', label: 'Özel HTML' },
|
||||
] as const
|
||||
|
||||
export function getBlockTypeLabel(type: string) {
|
||||
return blockTypes.find((bt) => bt.value === type)?.label ?? type
|
||||
}
|
||||
535
src/components/block-manager/index.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
GripVertical,
|
||||
Image,
|
||||
Layout,
|
||||
Loader2,
|
||||
MessageSquareQuote,
|
||||
Monitor,
|
||||
Newspaper,
|
||||
Play,
|
||||
Plus,
|
||||
RectangleHorizontal,
|
||||
RefreshCw,
|
||||
SquareStack,
|
||||
Trash2,
|
||||
Type,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import type { BlockApi } from '@/lib/api/blocks'
|
||||
import type { AdminPageBlockRecord } from '@/lib/api/types'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { blockTypes, getBlockTypeLabel } from './block-helpers'
|
||||
import {
|
||||
BlockContentFields,
|
||||
contentToFieldValues,
|
||||
fieldValuesToContent,
|
||||
getBlockPreviewText,
|
||||
} from './block-content-fields'
|
||||
|
||||
const blockIconMap: Record<string, React.ElementType> = {
|
||||
hero: Monitor,
|
||||
text: Type,
|
||||
text_image: Newspaper,
|
||||
stats_grid: SquareStack,
|
||||
cta: Layout,
|
||||
faq: MessageSquareQuote,
|
||||
gallery: Image,
|
||||
video: Play,
|
||||
cards: SquareStack,
|
||||
testimonials: MessageSquareQuote,
|
||||
html: Type,
|
||||
}
|
||||
|
||||
function getBlockIcon(type: string) {
|
||||
return blockIconMap[type] ?? Type
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Inline block card */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function BlockCard({
|
||||
block,
|
||||
index,
|
||||
totalBlocks,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onSave,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSaving,
|
||||
isReordering,
|
||||
}: {
|
||||
block: AdminPageBlockRecord
|
||||
index: number
|
||||
totalBlocks: number
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onSave: (type: string, content: Record<string, unknown>) => void
|
||||
onDelete: () => void
|
||||
onMove: (direction: 'up' | 'down') => void
|
||||
isSaving: boolean
|
||||
isReordering: boolean
|
||||
}) {
|
||||
const currentWidth =
|
||||
(block.content?.['_width'] as string | undefined) ?? 'full'
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
|
||||
contentToFieldValues(block.type, block.content)
|
||||
)
|
||||
const [editType, setEditType] = useState(block.type)
|
||||
const [width, setWidth] = useState<'full' | 'half'>(
|
||||
currentWidth === 'half' ? 'half' : 'full'
|
||||
)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setFieldValues(contentToFieldValues(block.type, block.content))
|
||||
setEditType(block.type)
|
||||
setWidth(
|
||||
(block.content?.['_width'] as string | undefined) === 'half'
|
||||
? 'half'
|
||||
: 'full'
|
||||
)
|
||||
setDirty(false)
|
||||
}, [block])
|
||||
|
||||
function handleFieldChange(next: Record<string, string>) {
|
||||
setFieldValues(next)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
function handleTypeChange(nextType: string) {
|
||||
setEditType(nextType)
|
||||
setFieldValues(contentToFieldValues(nextType, null))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
function handleWidthToggle() {
|
||||
const next = width === 'full' ? 'half' : 'full'
|
||||
setWidth(next)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const content = fieldValuesToContent(fieldValues)
|
||||
content['_width'] = width
|
||||
onSave(editType, content)
|
||||
}
|
||||
|
||||
const Icon = getBlockIcon(block.type)
|
||||
const preview = getBlockPreviewText(block.type, block.content)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-2xl border bg-card shadow-sm transition-all',
|
||||
isExpanded
|
||||
? 'border-primary/30 ring-1 ring-primary/10'
|
||||
: 'border-border/70 hover:border-border'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-2 px-3 py-2.5'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-5 rounded'
|
||||
disabled={index === 0 || isReordering}
|
||||
onClick={() => onMove('up')}
|
||||
>
|
||||
<ArrowUp className='size-3' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-5 rounded'
|
||||
disabled={index === totalBlocks - 1 || isReordering}
|
||||
onClick={() => onMove('down')}
|
||||
>
|
||||
<ArrowDown className='size-3' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<GripVertical className='size-4 shrink-0 text-muted-foreground/40' />
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={onToggle}
|
||||
className='flex min-w-0 flex-1 items-center gap-3 text-left'
|
||||
>
|
||||
<div className='flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted'>
|
||||
<Icon className='size-4 text-muted-foreground' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='shrink-0 rounded-full text-xs'
|
||||
>
|
||||
{getBlockTypeLabel(block.type)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={width === 'half' ? 'default' : 'secondary'}
|
||||
className='shrink-0 rounded-full px-1.5 py-0 text-[10px]'
|
||||
>
|
||||
{width === 'half' ? '½ Yarım' : 'Tam'}
|
||||
</Badge>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
#{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
{!isExpanded && (
|
||||
<p className='mt-0.5 truncate text-xs text-muted-foreground'>
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8 shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive'
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className='size-3.5' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className='border-t border-border/50 bg-muted/5 px-4 py-4'>
|
||||
<div className='mb-4 flex flex-wrap items-center gap-3'>
|
||||
<div className='max-w-xs flex-1'>
|
||||
<Select value={editType} onValueChange={handleTypeChange}>
|
||||
<SelectTrigger className='rounded-xl'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{blockTypes.map((bt) => (
|
||||
<SelectItem key={bt.value} value={bt.value}>
|
||||
{bt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-center gap-1 rounded-xl border border-border/70 p-0.5'>
|
||||
<Button
|
||||
type='button'
|
||||
variant={width === 'full' ? 'default' : 'ghost'}
|
||||
size='sm'
|
||||
className='h-7 gap-1 rounded-lg px-2 text-xs'
|
||||
onClick={() => {
|
||||
if (width !== 'full') handleWidthToggle()
|
||||
}}
|
||||
>
|
||||
<RectangleHorizontal className='size-3' />
|
||||
Tam
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant={width === 'half' ? 'default' : 'ghost'}
|
||||
size='sm'
|
||||
className='h-7 gap-1 rounded-lg px-2 text-xs'
|
||||
onClick={() => {
|
||||
if (width !== 'half') handleWidthToggle()
|
||||
}}
|
||||
>
|
||||
<Columns2 className='size-3' />
|
||||
Yarım
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BlockContentFields
|
||||
type={editType}
|
||||
values={fieldValues}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
<div className='mt-4 flex items-center justify-end gap-2 border-t border-border/50 pt-3'>
|
||||
{dirty && (
|
||||
<span className='mr-auto text-xs text-amber-600'>
|
||||
Kaydedilmemiş değişiklikler
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
className='rounded-xl'
|
||||
disabled={!dirty || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? <Loader2 className='animate-spin' /> : <Check />}
|
||||
Kaydet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* New block picker */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function NewBlockPicker({
|
||||
onAdd,
|
||||
isPending,
|
||||
}: {
|
||||
onAdd: (type: string) => void
|
||||
isPending: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpen(true)}
|
||||
className='flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/60 py-4 text-sm text-muted-foreground transition hover:border-primary/40 hover:text-foreground'
|
||||
>
|
||||
<Plus className='size-4' />
|
||||
Yeni blok ekle
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-2xl border border-primary/20 bg-card p-4 shadow-sm'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<p className='text-sm font-medium'>Blok tipi seçin</p>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-7'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4'>
|
||||
{blockTypes.map((bt) => {
|
||||
const BtIcon = getBlockIcon(bt.value)
|
||||
return (
|
||||
<button
|
||||
key={bt.value}
|
||||
type='button'
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
onAdd(bt.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className='flex flex-col items-center gap-1.5 rounded-xl border border-border/70 bg-background p-3 text-center transition hover:border-primary/40 hover:bg-primary/5 disabled:opacity-50'
|
||||
>
|
||||
<BtIcon className='size-5 text-muted-foreground' />
|
||||
<span className='text-xs font-medium'>{bt.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Block manager (generic) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function BlockManager({ api }: { api: BlockApi }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [expandedId, setExpandedId] = useState<number | string | null>(null)
|
||||
const [blockToDelete, setBlockToDelete] =
|
||||
useState<AdminPageBlockRecord | null>(null)
|
||||
|
||||
const blocksQuery = useQuery({
|
||||
queryKey: [...api.queryKey],
|
||||
queryFn: () => api.fetchBlocks(),
|
||||
})
|
||||
|
||||
const blocks = blocksQuery.data ?? []
|
||||
|
||||
const invalidateBlocks = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: [...api.queryKey] })
|
||||
await queryClient.invalidateQueries({ queryKey: [...api.parentQueryKey] })
|
||||
}, [queryClient, api.queryKey, api.parentQueryKey])
|
||||
|
||||
const addBlockMutation = useMutation({
|
||||
mutationFn: async (type: string) =>
|
||||
api.createBlock({
|
||||
type,
|
||||
content: { title: '' },
|
||||
order_index: blocks.length,
|
||||
}),
|
||||
onSuccess: async (newBlock) => {
|
||||
await invalidateBlocks()
|
||||
setExpandedId(newBlock.id)
|
||||
toast.success('Blok eklendi')
|
||||
},
|
||||
onError: handleServerError,
|
||||
})
|
||||
|
||||
const updateBlockMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
blockId,
|
||||
type,
|
||||
content,
|
||||
}: {
|
||||
blockId: number | string
|
||||
type: string
|
||||
content: Record<string, unknown>
|
||||
}) => api.updateBlock(blockId, { type, content }),
|
||||
onSuccess: async () => {
|
||||
await invalidateBlocks()
|
||||
toast.success('Blok güncellendi')
|
||||
},
|
||||
onError: handleServerError,
|
||||
})
|
||||
|
||||
const deleteBlockMutation = useMutation({
|
||||
mutationFn: async (blockId: number | string) => api.deleteBlock(blockId),
|
||||
onSuccess: async () => {
|
||||
await invalidateBlocks()
|
||||
setBlockToDelete(null)
|
||||
setExpandedId(null)
|
||||
toast.success('Blok silindi')
|
||||
},
|
||||
onError: handleServerError,
|
||||
})
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: async (
|
||||
items: Array<{ id: number | string; order_index: number }>
|
||||
) => api.reorderBlocks(items),
|
||||
onSuccess: async () => {
|
||||
await invalidateBlocks()
|
||||
},
|
||||
onError: handleServerError,
|
||||
})
|
||||
|
||||
function moveBlock(index: number, direction: 'up' | 'down') {
|
||||
const swapIndex = direction === 'up' ? index - 1 : index + 1
|
||||
if (swapIndex < 0 || swapIndex >= blocks.length) return
|
||||
|
||||
const reordered = [...blocks]
|
||||
const temp = reordered[index]
|
||||
reordered[index] = reordered[swapIndex]
|
||||
reordered[swapIndex] = temp
|
||||
|
||||
reorderMutation.mutate(
|
||||
reordered.map((block, idx) => ({ id: block.id, order_index: idx }))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='text-sm font-semibold'>İçerik Blokları</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{blocks.length} blok — Blokları sıralayın, düzenleyin veya
|
||||
yenilerini ekleyin
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='rounded-xl'
|
||||
onClick={() => blocksQuery.refetch()}
|
||||
disabled={blocksQuery.isFetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={blocksQuery.isFetching ? 'animate-spin' : ''}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{blocksQuery.isLoading ? (
|
||||
<div className='flex items-center justify-center rounded-2xl border border-dashed border-border/70 py-12 text-muted-foreground'>
|
||||
<Loader2 className='mr-2 size-5 animate-spin' />
|
||||
Bloklar yükleniyor...
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
{blocks.map((block, index) => (
|
||||
<BlockCard
|
||||
key={block.id}
|
||||
block={block}
|
||||
index={index}
|
||||
totalBlocks={blocks.length}
|
||||
isExpanded={expandedId === block.id}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === block.id ? null : block.id)
|
||||
}
|
||||
onSave={(type, content) =>
|
||||
updateBlockMutation.mutate({
|
||||
blockId: block.id,
|
||||
type,
|
||||
content,
|
||||
})
|
||||
}
|
||||
onDelete={() => setBlockToDelete(block)}
|
||||
onMove={(dir) => moveBlock(index, dir)}
|
||||
isSaving={updateBlockMutation.isPending}
|
||||
isReordering={reorderMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
|
||||
<NewBlockPicker
|
||||
onAdd={(type) => addBlockMutation.mutate(type)}
|
||||
isPending={addBlockMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!blockToDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setBlockToDelete(null)
|
||||
}}
|
||||
title='Bloğu silmek istediğinize emin misiniz?'
|
||||
desc={`"${getBlockTypeLabel(blockToDelete?.type ?? '')}" bloğu kalıcı olarak silinecek.`}
|
||||
confirmText='Evet, sil'
|
||||
cancelBtnText='İptal'
|
||||
destructive
|
||||
isLoading={deleteBlockMutation.isPending}
|
||||
handleConfirm={() => {
|
||||
if (blockToDelete) {
|
||||
deleteBlockMutation.mutate(blockToDelete.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
src/components/coming-soon.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Telescope } from 'lucide-react'
|
||||
|
||||
export function ComingSoon() {
|
||||
return (
|
||||
<div className='h-svh'>
|
||||
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
|
||||
<Telescope size={72} />
|
||||
<h1 className='text-4xl leading-tight font-bold'>Coming Soon!</h1>
|
||||
<p className='text-center text-muted-foreground'>
|
||||
This page has not been created yet. <br />
|
||||
Stay tuned though!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
src/components/command-menu.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import { sidebarData } from './layout/data/sidebar-data'
|
||||
import { ScrollArea } from './ui/scroll-area'
|
||||
|
||||
export function CommandMenu() {
|
||||
const navigate = useNavigate()
|
||||
const { setTheme } = useTheme()
|
||||
const { open, setOpen } = useSearch()
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
(command: () => unknown) => {
|
||||
setOpen(false)
|
||||
command()
|
||||
},
|
||||
[setOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<CommandDialog modal open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder='Type a command or search...' />
|
||||
<CommandList>
|
||||
<ScrollArea type='hover' className='h-72 pe-1'>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{sidebarData.navGroups.map((group) => (
|
||||
<CommandGroup key={group.title} heading={group.title}>
|
||||
{group.items.map((navItem, i) => {
|
||||
if (navItem.url)
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${navItem.url}-${i}`}
|
||||
value={navItem.title}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: navItem.url }))
|
||||
}}
|
||||
>
|
||||
<div className='flex size-4 items-center justify-center'>
|
||||
<ArrowRight className='size-2 text-muted-foreground/80' />
|
||||
</div>
|
||||
{navItem.title}
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
return navItem.items?.map((subItem, i) => (
|
||||
<CommandItem
|
||||
key={`${navItem.title}-${subItem.url}-${i}`}
|
||||
value={`${navItem.title}-${subItem.url}`}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: subItem.url }))
|
||||
}}
|
||||
>
|
||||
<div className='flex size-4 items-center justify-center'>
|
||||
<ArrowRight className='size-2 text-muted-foreground/80' />
|
||||
</div>
|
||||
{navItem.title} <ChevronRight /> {subItem.title}
|
||||
</CommandItem>
|
||||
))
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading='Theme'>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
|
||||
<Sun /> <span>Light</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
|
||||
<Moon className='scale-90' />
|
||||
<span>Dark</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('system'))}>
|
||||
<Laptop />
|
||||
<span>System</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
354
src/components/config-drawer.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { Root as Radio, Item } from '@radix-ui/react-radio-group'
|
||||
import { CircleCheck, RotateCcw, Settings } from 'lucide-react'
|
||||
import { IconDir } from '@/assets/custom/icon-dir'
|
||||
import { IconLayoutCompact } from '@/assets/custom/icon-layout-compact'
|
||||
import { IconLayoutDefault } from '@/assets/custom/icon-layout-default'
|
||||
import { IconLayoutFull } from '@/assets/custom/icon-layout-full'
|
||||
import { IconSidebarFloating } from '@/assets/custom/icon-sidebar-floating'
|
||||
import { IconSidebarInset } from '@/assets/custom/icon-sidebar-inset'
|
||||
import { IconSidebarSidebar } from '@/assets/custom/icon-sidebar-sidebar'
|
||||
import { IconThemeDark } from '@/assets/custom/icon-theme-dark'
|
||||
import { IconThemeLight } from '@/assets/custom/icon-theme-light'
|
||||
import { IconThemeSystem } from '@/assets/custom/icon-theme-system'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDirection } from '@/context/direction-provider'
|
||||
import { type Collapsible, useLayout } from '@/context/layout-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { useSidebar } from './ui/sidebar'
|
||||
|
||||
export function ConfigDrawer() {
|
||||
const { setOpen } = useSidebar()
|
||||
const { resetDir } = useDirection()
|
||||
const { resetTheme } = useTheme()
|
||||
const { resetLayout } = useLayout()
|
||||
|
||||
const handleReset = () => {
|
||||
setOpen(true)
|
||||
resetDir()
|
||||
resetTheme()
|
||||
resetLayout()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
aria-label='Open theme settings'
|
||||
aria-describedby='config-drawer-description'
|
||||
className='rounded-full'
|
||||
>
|
||||
<Settings aria-hidden='true' />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='flex flex-col'>
|
||||
<SheetHeader className='pb-0 text-start'>
|
||||
<SheetTitle>Theme Settings</SheetTitle>
|
||||
<SheetDescription id='config-drawer-description'>
|
||||
Adjust the appearance and layout to suit your preferences.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='space-y-6 overflow-y-auto px-4'>
|
||||
<ThemeConfig />
|
||||
<SidebarConfig />
|
||||
<LayoutConfig />
|
||||
<DirConfig />
|
||||
</div>
|
||||
<SheetFooter className='gap-2'>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleReset}
|
||||
aria-label='Reset all settings to default values'
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({
|
||||
title,
|
||||
showReset = false,
|
||||
onReset,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
showReset?: boolean
|
||||
onReset?: () => void
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2 flex items-center gap-2 text-sm font-semibold text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
{showReset && onReset && (
|
||||
<Button
|
||||
size='icon'
|
||||
variant='secondary'
|
||||
className='size-4 rounded-full'
|
||||
onClick={onReset}
|
||||
>
|
||||
<RotateCcw className='size-3' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
item,
|
||||
isTheme = false,
|
||||
}: {
|
||||
item: {
|
||||
value: string
|
||||
label: string
|
||||
icon: (props: SVGProps<SVGSVGElement>) => React.ReactElement
|
||||
}
|
||||
isTheme?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Item
|
||||
value={item.value}
|
||||
className={cn('group outline-none', 'transition duration-200 ease-in')}
|
||||
aria-label={`Select ${item.label.toLowerCase()}`}
|
||||
aria-describedby={`${item.value}-description`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-[6px] ring-[1px] ring-border',
|
||||
'group-data-[state=checked]:shadow-2xl group-data-[state=checked]:ring-primary',
|
||||
'group-focus-visible:ring-2'
|
||||
)}
|
||||
role='img'
|
||||
aria-hidden='false'
|
||||
aria-label={`${item.label} option preview`}
|
||||
>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
'size-6 fill-primary stroke-white',
|
||||
'group-data-[state=unchecked]:hidden',
|
||||
'absolute top-0 right-0 translate-x-1/2 -translate-y-1/2'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<item.icon
|
||||
className={cn(
|
||||
!isTheme &&
|
||||
'fill-primary stroke-primary group-data-[state=unchecked]:fill-muted-foreground group-data-[state=unchecked]:stroke-muted-foreground'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className='mt-1 text-xs'
|
||||
id={`${item.value}-description`}
|
||||
aria-live='polite'
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeConfig() {
|
||||
const { defaultTheme, theme, setTheme } = useTheme()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title='Theme'
|
||||
showReset={theme !== defaultTheme}
|
||||
onReset={() => setTheme(defaultTheme)}
|
||||
/>
|
||||
<Radio
|
||||
value={theme}
|
||||
onValueChange={setTheme}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label='Select theme preference'
|
||||
aria-describedby='theme-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'system',
|
||||
label: 'System',
|
||||
icon: IconThemeSystem,
|
||||
},
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Light',
|
||||
icon: IconThemeLight,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Dark',
|
||||
icon: IconThemeDark,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} isTheme />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='theme-description' className='sr-only'>
|
||||
Choose between system preference, light mode, or dark mode
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarConfig() {
|
||||
const { defaultVariant, variant, setVariant } = useLayout()
|
||||
return (
|
||||
<div className='max-md:hidden'>
|
||||
<SectionTitle
|
||||
title='Sidebar'
|
||||
showReset={defaultVariant !== variant}
|
||||
onReset={() => setVariant(defaultVariant)}
|
||||
/>
|
||||
<Radio
|
||||
value={variant}
|
||||
onValueChange={setVariant}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label='Select sidebar style'
|
||||
aria-describedby='sidebar-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'inset',
|
||||
label: 'Inset',
|
||||
icon: IconSidebarInset,
|
||||
},
|
||||
{
|
||||
value: 'floating',
|
||||
label: 'Floating',
|
||||
icon: IconSidebarFloating,
|
||||
},
|
||||
{
|
||||
value: 'sidebar',
|
||||
label: 'Sidebar',
|
||||
icon: IconSidebarSidebar,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='sidebar-description' className='sr-only'>
|
||||
Choose between inset, floating, or standard sidebar layout
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutConfig() {
|
||||
const { open, setOpen } = useSidebar()
|
||||
const { defaultCollapsible, collapsible, setCollapsible } = useLayout()
|
||||
|
||||
const radioState = open ? 'default' : collapsible
|
||||
|
||||
return (
|
||||
<div className='max-md:hidden'>
|
||||
<SectionTitle
|
||||
title='Layout'
|
||||
showReset={radioState !== 'default'}
|
||||
onReset={() => {
|
||||
setOpen(true)
|
||||
setCollapsible(defaultCollapsible)
|
||||
}}
|
||||
/>
|
||||
<Radio
|
||||
value={radioState}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'default') {
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
setCollapsible(v as Collapsible)
|
||||
}}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label='Select layout style'
|
||||
aria-describedby='layout-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default',
|
||||
icon: IconLayoutDefault,
|
||||
},
|
||||
{
|
||||
value: 'icon',
|
||||
label: 'Compact',
|
||||
icon: IconLayoutCompact,
|
||||
},
|
||||
{
|
||||
value: 'offcanvas',
|
||||
label: 'Full layout',
|
||||
icon: IconLayoutFull,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='layout-description' className='sr-only'>
|
||||
Choose between default expanded, compact icon-only, or full layout mode
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DirConfig() {
|
||||
const { defaultDir, dir, setDir } = useDirection()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title='Direction'
|
||||
showReset={defaultDir !== dir}
|
||||
onReset={() => setDir(defaultDir)}
|
||||
/>
|
||||
<Radio
|
||||
value={dir}
|
||||
onValueChange={setDir}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label='Select site direction'
|
||||
aria-describedby='direction-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'ltr',
|
||||
label: 'Left to Right',
|
||||
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||
<IconDir dir='ltr' {...props} />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'rtl',
|
||||
label: 'Right to Left',
|
||||
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||
<IconDir dir='rtl' {...props} />
|
||||
),
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='direction-description' className='sr-only'>
|
||||
Choose between left-to-right or right-to-left site direction
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
src/components/confirm-dialog.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: React.ReactNode
|
||||
disabled?: boolean
|
||||
desc: React.JSX.Element | string
|
||||
cancelBtnText?: string
|
||||
confirmText?: React.ReactNode
|
||||
destructive?: boolean
|
||||
handleConfirm: () => void
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const {
|
||||
title,
|
||||
desc,
|
||||
children,
|
||||
className,
|
||||
confirmText,
|
||||
cancelBtnText,
|
||||
destructive,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
handleConfirm,
|
||||
...actions
|
||||
} = props
|
||||
return (
|
||||
<AlertDialog {...actions}>
|
||||
<AlertDialogContent className={cn(className && className)}>
|
||||
<AlertDialogHeader className='text-start'>
|
||||
<div className='mb-4 flex size-14 items-center justify-center rounded-full bg-destructive/10 text-destructive'>
|
||||
<AlertTriangle className='size-6' />
|
||||
</div>
|
||||
<AlertDialogTitle className='text-xl'>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className='text-sm leading-6'>{desc}</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{children}
|
||||
<AlertDialogFooter className='gap-2 sm:justify-start'>
|
||||
<AlertDialogCancel className='rounded-xl' disabled={isLoading}>
|
||||
{cancelBtnText ?? 'İptal'}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
className='rounded-xl'
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : null}
|
||||
{confirmText ?? 'Sil'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
213
src/components/data-table/bulk-actions.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type DataTableBulkActionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
entityName: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A modular toolbar for displaying bulk actions when table rows are selected.
|
||||
*
|
||||
* @template TData The type of data in the table.
|
||||
* @param {object} props The component props.
|
||||
* @param {Table<TData>} props.table The react-table instance.
|
||||
* @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
|
||||
* @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
|
||||
* @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
|
||||
*/
|
||||
export function DataTableBulkActions<TData>({
|
||||
table,
|
||||
entityName,
|
||||
children,
|
||||
}: DataTableBulkActionsProps<TData>): React.ReactNode | null {
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedCount = selectedRows.length
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
|
||||
// Announce selection changes to screen readers
|
||||
useEffect(() => {
|
||||
if (selectedCount > 0) {
|
||||
const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
|
||||
|
||||
// Use queueMicrotask to defer state update and avoid cascading renders
|
||||
queueMicrotask(() => {
|
||||
setAnnouncement(message)
|
||||
})
|
||||
|
||||
// Clear announcement after a delay
|
||||
const timer = setTimeout(() => setAnnouncement(''), 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [selectedCount, entityName])
|
||||
|
||||
const handleClearSelection = () => {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const buttons = toolbarRef.current?.querySelectorAll('button')
|
||||
if (!buttons) return
|
||||
|
||||
const currentIndex = Array.from(buttons).findIndex(
|
||||
(button) => button === document.activeElement
|
||||
)
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowRight': {
|
||||
event.preventDefault()
|
||||
const nextIndex = (currentIndex + 1) % buttons.length
|
||||
buttons[nextIndex]?.focus()
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
event.preventDefault()
|
||||
const prevIndex =
|
||||
currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
|
||||
buttons[prevIndex]?.focus()
|
||||
break
|
||||
}
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
buttons[0]?.focus()
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
buttons[buttons.length - 1]?.focus()
|
||||
break
|
||||
case 'Escape': {
|
||||
// Check if the Escape key came from a dropdown trigger or content
|
||||
// We can't check dropdown state because Radix UI closes it before our handler runs
|
||||
const target = event.target as HTMLElement
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
|
||||
// Check if the event target or currently focused element is a dropdown trigger
|
||||
const isFromDropdownTrigger =
|
||||
target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
|
||||
activeElement?.getAttribute('data-slot') ===
|
||||
'dropdown-menu-trigger' ||
|
||||
target?.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||
activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
|
||||
|
||||
// Check if the focused element is inside dropdown content (which is portaled)
|
||||
const isFromDropdownContent =
|
||||
activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
|
||||
target?.closest('[data-slot="dropdown-menu-content"]')
|
||||
|
||||
if (isFromDropdownTrigger || isFromDropdownContent) {
|
||||
// Escape was meant for the dropdown - don't clear selection
|
||||
return
|
||||
}
|
||||
|
||||
// Escape was meant for the toolbar - clear selection
|
||||
event.preventDefault()
|
||||
handleClearSelection()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Live region for screen reader announcements */}
|
||||
<div
|
||||
aria-live='polite'
|
||||
aria-atomic='true'
|
||||
className='sr-only'
|
||||
role='status'
|
||||
>
|
||||
{announcement}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
role='toolbar'
|
||||
aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`}
|
||||
aria-describedby='bulk-actions-description'
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
|
||||
'transition-all delay-100 duration-300 ease-out hover:scale-105',
|
||||
'focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 shadow-xl',
|
||||
'rounded-xl border',
|
||||
'bg-background/95 backdrop-blur-lg supports-backdrop-filter:bg-background/60',
|
||||
'flex items-center gap-x-2'
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleClearSelection}
|
||||
className='size-6 rounded-full'
|
||||
aria-label='Clear selection'
|
||||
title='Clear selection (Escape)'
|
||||
>
|
||||
<X />
|
||||
<span className='sr-only'>Clear selection</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Clear selection (Escape)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator
|
||||
className='h-5'
|
||||
orientation='vertical'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
<div
|
||||
className='flex items-center gap-x-1 text-sm'
|
||||
id='bulk-actions-description'
|
||||
>
|
||||
<Badge
|
||||
variant='default'
|
||||
className='min-w-8 rounded-lg'
|
||||
aria-label={`${selectedCount} selected`}
|
||||
>
|
||||
{selectedCount}
|
||||
</Badge>{' '}
|
||||
<span className='hidden sm:inline'>
|
||||
{entityName}
|
||||
{selectedCount > 1 ? 's' : ''}
|
||||
</span>{' '}
|
||||
selected
|
||||
</div>
|
||||
|
||||
<Separator
|
||||
className='h-5'
|
||||
orientation='vertical'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
74
src/components/data-table/column-header.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CaretSortIcon,
|
||||
EyeNoneIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
type DataTableColumnHeaderProps<TData, TValue> =
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 data-[state=open]:bg-accent'
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className='ms-2 h-4 w-4' />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className='ms-2 h-4 w-4' />
|
||||
) : (
|
||||
<CaretSortIcon className='ms-2 h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className='size-3.5 text-muted-foreground/70' />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className='size-3.5 text-muted-foreground/70' />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
{column.getCanHide() && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className='size-3.5 text-muted-foreground/70' />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
src/components/data-table/faceted-filter.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as React from 'react'
|
||||
import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
type DataTableFacetedFilterProps<TData, TValue> = {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}
|
||||
|
||||
export function DataTableFacetedFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' size='sm' className='h-8 border-dashed'>
|
||||
<PlusCircledIcon className='size-4' />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation='vertical' className='mx-2 h-4' />
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal lg:hidden'
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className='hidden space-x-1 lg:flex'>
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
key={option.value}
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[200px] p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-4 items-center justify-center rounded-sm border border-primary',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn('h-4 w-4 text-background')} />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className='size-4 text-muted-foreground' />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => column?.setFilterValue(undefined)}
|
||||
className='justify-center text-center'
|
||||
>
|
||||
Clear filters
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
6
src/components/data-table/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { DataTablePagination } from './pagination'
|
||||
export { DataTableColumnHeader } from './column-header'
|
||||
export { DataTableFacetedFilter } from './faceted-filter'
|
||||
export { DataTableViewOptions } from './view-options'
|
||||
export { DataTableToolbar } from './toolbar'
|
||||
export { DataTableBulkActions } from './bulk-actions'
|
||||
130
src/components/data-table/pagination.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { cn, getPageNumbers } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
type DataTablePaginationProps<TData> = {
|
||||
table: Table<TData>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
className,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const currentPage = table.getState().pagination.pageIndex + 1
|
||||
const totalPages = table.getPageCount()
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between overflow-clip px-2',
|
||||
'@max-2xl/content:flex-col-reverse @max-2xl/content:gap-4',
|
||||
className
|
||||
)}
|
||||
style={{ overflowClipMargin: 1 }}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex w-[100px] items-center justify-center text-sm font-medium @2xl/content:hidden'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 @max-2xl/content:flex-row-reverse'>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[70px]'>
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center sm:space-x-6 lg:space-x-8'>
|
||||
<div className='flex w-[100px] items-center justify-center text-sm font-medium @max-3xl/content:hidden'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to first page</span>
|
||||
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to previous page</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{/* Page number buttons */}
|
||||
{pageNumbers.map((pageNumber, index) => (
|
||||
<div key={`${pageNumber}-${index}`} className='flex items-center'>
|
||||
{pageNumber === '...' ? (
|
||||
<span className='px-1 text-sm text-muted-foreground'>...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === pageNumber ? 'default' : 'outline'}
|
||||
className='h-8 min-w-8 px-2'
|
||||
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
|
||||
>
|
||||
<span className='sr-only'>Go to page {pageNumber}</span>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0'
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to next page</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to last page</span>
|
||||
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
src/components/data-table/toolbar.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Cross2Icon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DataTableFacetedFilter } from './faceted-filter'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
|
||||
type DataTableToolbarProps<TData> = {
|
||||
table: Table<TData>
|
||||
searchPlaceholder?: string
|
||||
searchKey?: string
|
||||
filters?: {
|
||||
columnId: string
|
||||
title: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
searchPlaceholder = 'Filter...',
|
||||
searchKey,
|
||||
filters = [],
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const isFiltered =
|
||||
table.getState().columnFilters.length > 0 || table.getState().globalFilter
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
|
||||
{searchKey ? (
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={
|
||||
(table.getColumn(searchKey)?.getFilterValue() as string) ?? ''
|
||||
}
|
||||
onChange={(event) =>
|
||||
table.getColumn(searchKey)?.setFilterValue(event.target.value)
|
||||
}
|
||||
className='h-8 w-[150px] lg:w-[250px]'
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={table.getState().globalFilter ?? ''}
|
||||
onChange={(event) => table.setGlobalFilter(event.target.value)}
|
||||
className='h-8 w-[150px] lg:w-[250px]'
|
||||
/>
|
||||
)}
|
||||
<div className='flex gap-x-2'>
|
||||
{filters.map((filter) => {
|
||||
const column = table.getColumn(filter.columnId)
|
||||
if (!column) return null
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
key={filter.columnId}
|
||||
column={column}
|
||||
title={filter.title}
|
||||
options={filter.options}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isFiltered && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
table.resetColumnFilters()
|
||||
table.setGlobalFilter('')
|
||||
}}
|
||||
className='h-8 px-2 lg:px-3'
|
||||
>
|
||||
Reset
|
||||
<Cross2Icon className='ms-2 h-4 w-4' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/components/data-table/view-options.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
|
||||
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
type DataTableViewOptionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='ms-auto hidden h-8 lg:flex'
|
||||
>
|
||||
<MixerHorizontalIcon className='size-4' />
|
||||
View
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[150px]'>
|
||||
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className='capitalize'
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
51
src/components/date-picker.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { format } from 'date-fns'
|
||||
import { Calendar as CalendarIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
type DatePickerProps = {
|
||||
selected: Date | undefined
|
||||
onSelect: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
placeholder = 'Pick a date',
|
||||
}: DatePickerProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
data-empty={!selected}
|
||||
className='w-[240px] justify-start text-start font-normal data-[empty=true]:text-muted-foreground'
|
||||
>
|
||||
{selected ? (
|
||||
format(selected, 'MMM d, yyyy')
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
captionLayout='dropdown'
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
disabled={(date: Date) =>
|
||||
date > new Date() || date < new Date('1900-01-01')
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
128
src/components/image-upload-field.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useRef } from 'react'
|
||||
import { ImagePlus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const previewWidth = 300
|
||||
const previewHeight = 260
|
||||
|
||||
export function ImageUploadField({
|
||||
value,
|
||||
onChange,
|
||||
previewUrl,
|
||||
pendingFileName,
|
||||
isSaving,
|
||||
onFileSelect,
|
||||
onClearSelection,
|
||||
label = 'Kapak görseli',
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
previewUrl: string | null
|
||||
pendingFileName: string | null
|
||||
isSaving: boolean
|
||||
onFileSelect: (file: File) => void
|
||||
onClearSelection: () => void
|
||||
label?: string
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
return (
|
||||
<div className='space-y-4 rounded-3xl border border-border/70 bg-card p-5 shadow-sm'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-sm font-semibold'>{label}</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
JPG, PNG, GIF, SVG veya WEBP
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='file'
|
||||
accept='image/png,image/jpeg,image/jpg,image/gif,image/svg+xml,image/webp'
|
||||
className='hidden'
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
|
||||
if (file) {
|
||||
onFileSelect(file)
|
||||
}
|
||||
|
||||
event.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='relative mx-auto w-fit'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isSaving}
|
||||
className='group relative block overflow-hidden rounded-3xl border border-dashed border-border/70 bg-muted/20 transition hover:border-primary/40 disabled:pointer-events-none disabled:opacity-60'
|
||||
>
|
||||
{previewUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt='Görsel önizleme'
|
||||
className='bg-background object-contain transition group-hover:scale-[1.02]'
|
||||
style={{ width: previewWidth, height: previewHeight }}
|
||||
/>
|
||||
<div className='absolute inset-0 bg-black/0 transition group-hover:bg-black/10' />
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className='flex flex-col items-center justify-center gap-3 text-muted-foreground transition group-hover:text-foreground'
|
||||
style={{ width: previewWidth, height: previewHeight }}
|
||||
>
|
||||
<div className='rounded-full border border-border/70 bg-background p-3'>
|
||||
<ImagePlus className='size-5' />
|
||||
</div>
|
||||
<div className='space-y-1 text-center'>
|
||||
<p className='text-sm font-medium'>Görsel seç</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{previewWidth} x {previewHeight} önizleme
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className='absolute top-3 right-3 flex gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
size='icon'
|
||||
className='size-9 rounded-full bg-background/95 text-foreground shadow-sm backdrop-blur hover:bg-background'
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<ImagePlus className='size-4' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='size-9 rounded-full bg-background/95 shadow-sm backdrop-blur'
|
||||
onClick={() => {
|
||||
onChange('')
|
||||
onClearSelection()
|
||||
}}
|
||||
disabled={(!value && !pendingFileName) || isSaving}
|
||||
>
|
||||
<X className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingFileName ? (
|
||||
<div className='rounded-2xl border border-sky-200 bg-sky-50 px-3 py-2 text-center text-sm text-sky-900'>
|
||||
<span className='font-medium'>{pendingFileName}</span> seçildi.
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-center text-xs text-muted-foreground'>
|
||||
Önizleme üzerine tıklayarak görsel seçebilirsiniz.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
src/components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useLayout } from '@/context/layout-provider'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { sidebarData } from './data/sidebar-data'
|
||||
import { NavGroup } from './nav-group'
|
||||
import { NavUser } from './nav-user'
|
||||
|
||||
function SidebarBrand() {
|
||||
return (
|
||||
<Link
|
||||
to='/'
|
||||
className='flex h-25 items-center justify-start px-2 text-sidebar-foreground group-data-[collapsible=icon]:h-12 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0'
|
||||
>
|
||||
<img
|
||||
src='/images/white-logo.svg'
|
||||
alt='Bodem Admin'
|
||||
className='h-25 w-auto object-contain group-data-[collapsible=icon]:hidden'
|
||||
/>
|
||||
<img
|
||||
src='/images/bodemfavicon.svg'
|
||||
alt='Bodem'
|
||||
className='hidden h-8 w-auto object-contain group-data-[collapsible=icon]:block'
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const { collapsible, variant } = useLayout()
|
||||
return (
|
||||
<Sidebar collapsible={collapsible} variant={variant}>
|
||||
<SidebarHeader>
|
||||
<SidebarBrand />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{sidebarData.navGroups.map((props) => (
|
||||
<NavGroup key={props.title} {...props} />
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={sidebarData.user} />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
64
src/components/layout/app-title.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
export function AppTitle() {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='gap-0 py-0 hover:bg-transparent active:bg-transparent'
|
||||
asChild
|
||||
>
|
||||
<div>
|
||||
<Link
|
||||
to='/'
|
||||
onClick={() => setOpenMobile(false)}
|
||||
className='grid flex-1 text-start text-sm leading-tight'
|
||||
>
|
||||
<span className='truncate font-bold'>Shadcn-Admin</span>
|
||||
<span className='truncate text-xs'>Vite + ShadcnUI</span>
|
||||
</Link>
|
||||
<ToggleSidebar />
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleSidebar({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar='trigger'
|
||||
data-slot='sidebar-trigger'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn('aspect-square size-8 max-md:scale-125', className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<X className='md:hidden' />
|
||||
<Menu className='max-md:hidden' />
|
||||
<span className='sr-only'>Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
42
src/components/layout/authenticated-layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
import { getCookie } from '@/lib/cookies'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LayoutProvider } from '@/context/layout-provider'
|
||||
import { SearchProvider } from '@/context/search-provider'
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { AppSidebar } from '@/components/layout/app-sidebar'
|
||||
import { SkipToMain } from '@/components/skip-to-main'
|
||||
|
||||
type AuthenticatedLayoutProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) {
|
||||
const defaultOpen = getCookie('sidebar_state') !== 'false'
|
||||
return (
|
||||
<SearchProvider>
|
||||
<LayoutProvider>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<SkipToMain />
|
||||
<AppSidebar />
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
// Set content container, so we can use container queries
|
||||
'@container/content',
|
||||
|
||||
// If layout is fixed, set the height
|
||||
// to 100svh to prevent overflow
|
||||
'has-data-[layout=fixed]:h-svh',
|
||||
|
||||
// If layout is fixed and sidebar is inset,
|
||||
// set the height to 100svh - spacing (total margins) to prevent overflow
|
||||
'peer-data-[variant=inset]:has-data-[layout=fixed]:h-[calc(100svh-(var(--spacing)*4))]'
|
||||
)}
|
||||
>
|
||||
{children ?? <Outlet />}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</LayoutProvider>
|
||||
</SearchProvider>
|
||||
)
|
||||
}
|
||||
135
src/components/layout/data/sidebar-data.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
BookOpen,
|
||||
CalendarRange,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
HelpCircle,
|
||||
Image,
|
||||
Instagram,
|
||||
LayoutDashboard,
|
||||
Map,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
import { type SidebarData } from '../types'
|
||||
|
||||
export const sidebarData: SidebarData = {
|
||||
user: {
|
||||
name: 'Operasyon Yoneticisi',
|
||||
email: 'yonetim@bogazicidenizcilik.com.tr',
|
||||
avatar: '',
|
||||
},
|
||||
teams: [
|
||||
{
|
||||
name: 'Bogazici Admin',
|
||||
logo: ShieldCheck,
|
||||
plan: 'Premium Control Center',
|
||||
},
|
||||
],
|
||||
navGroups: [
|
||||
{
|
||||
title: 'Yonetim',
|
||||
items: [
|
||||
{
|
||||
title: 'Genel Bakis',
|
||||
url: '/',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: 'Kullanicilar',
|
||||
url: '/users',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'Roller',
|
||||
url: '/roles',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Icerik',
|
||||
items: [
|
||||
{
|
||||
title: 'Kategoriler',
|
||||
url: '/categories',
|
||||
icon: FolderKanban,
|
||||
},
|
||||
{
|
||||
title: 'Egitimler',
|
||||
url: '/courses',
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
title: 'Takvim',
|
||||
url: '/schedules',
|
||||
icon: CalendarRange,
|
||||
},
|
||||
{
|
||||
title: 'Duyurular',
|
||||
url: '/announcements',
|
||||
icon: Megaphone,
|
||||
},
|
||||
{
|
||||
title: 'Hero Slider',
|
||||
url: '/hero-slides',
|
||||
icon: Image,
|
||||
},
|
||||
{
|
||||
title: 'Hikayeler',
|
||||
url: '/stories',
|
||||
icon: Instagram,
|
||||
},
|
||||
{
|
||||
title: 'SSS',
|
||||
url: '/faqs',
|
||||
icon: HelpCircle,
|
||||
},
|
||||
{
|
||||
title: 'Rehber Kartlari',
|
||||
url: '/guide-cards',
|
||||
icon: Map,
|
||||
},
|
||||
{
|
||||
title: 'Sayfalar',
|
||||
url: '/pages',
|
||||
icon: FileText,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'CRM',
|
||||
items: [
|
||||
{
|
||||
title: 'Basvurular',
|
||||
url: '/leads',
|
||||
icon: UserPlus,
|
||||
},
|
||||
{
|
||||
title: 'Yorumlar',
|
||||
url: '/comments',
|
||||
icon: MessageSquare,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Site Yonetimi',
|
||||
items: [
|
||||
{
|
||||
title: 'Menuler',
|
||||
url: '/menus',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: 'Ayarlar',
|
||||
url: '/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
50
src/components/layout/header.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
|
||||
type HeaderProps = React.HTMLAttributes<HTMLElement> & {
|
||||
fixed?: boolean
|
||||
ref?: React.Ref<HTMLElement>
|
||||
}
|
||||
|
||||
export function Header({ className, fixed, children, ...props }: HeaderProps) {
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
setOffset(document.body.scrollTop || document.documentElement.scrollTop)
|
||||
}
|
||||
|
||||
// Add scroll listener to the body
|
||||
document.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
// Clean up the event listener on unmount
|
||||
return () => document.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'z-50 h-16',
|
||||
fixed && 'header-fixed peer/header sticky top-0 w-[inherit]',
|
||||
offset > 10 && fixed ? 'shadow' : 'shadow-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full items-center gap-3 p-4 sm:gap-4',
|
||||
offset > 10 &&
|
||||
fixed &&
|
||||
'after:absolute after:inset-0 after:-z-10 after:bg-background/20 after:backdrop-blur-lg'
|
||||
)}
|
||||
>
|
||||
<SidebarTrigger variant='outline' className='max-md:scale-125' />
|
||||
<Separator orientation='vertical' className='h-6' />
|
||||
{children}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
27
src/components/layout/main.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type MainProps = React.HTMLAttributes<HTMLElement> & {
|
||||
fixed?: boolean
|
||||
fluid?: boolean
|
||||
ref?: React.Ref<HTMLElement>
|
||||
}
|
||||
|
||||
export function Main({ fixed, className, fluid, ...props }: MainProps) {
|
||||
return (
|
||||
<main
|
||||
data-layout={fixed ? 'fixed' : 'auto'}
|
||||
className={cn(
|
||||
'px-4 py-6',
|
||||
|
||||
// If layout is fixed, make the main container flex and grow
|
||||
fixed && 'flex grow flex-col overflow-hidden',
|
||||
|
||||
// If layout is not fluid, set the max-width
|
||||
!fluid &&
|
||||
'@7xl/content:mx-auto @7xl/content:w-full @7xl/content:max-w-7xl',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
185
src/components/layout/nav-group.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Badge } from '../ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu'
|
||||
import {
|
||||
type NavCollapsible,
|
||||
type NavItem,
|
||||
type NavLink,
|
||||
type NavGroup as NavGroupProps,
|
||||
} from './types'
|
||||
|
||||
export function NavGroup({ title, items }: NavGroupProps) {
|
||||
const { state, isMobile } = useSidebar()
|
||||
const href = useLocation({ select: (location) => location.href })
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{title}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const key = `${item.title}-${item.url}`
|
||||
|
||||
if (!item.items)
|
||||
return <SidebarMenuLink key={key} item={item} href={href} />
|
||||
|
||||
if (state === 'collapsed' && !isMobile)
|
||||
return (
|
||||
<SidebarMenuCollapsedDropdown key={key} item={item} href={href} />
|
||||
)
|
||||
|
||||
return <SidebarMenuCollapsible key={key} item={item} href={href} />
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function NavBadge({ children }: { children: ReactNode }) {
|
||||
return <Badge className='rounded-full px-1 py-0 text-xs'>{children}</Badge>
|
||||
}
|
||||
|
||||
function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={checkIsActive(href, item)}
|
||||
tooltip={item.title}
|
||||
>
|
||||
<Link to={item.url} onClick={() => setOpenMobile(false)}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuCollapsible({
|
||||
item,
|
||||
href,
|
||||
}: {
|
||||
item: NavCollapsible
|
||||
href: string
|
||||
}) {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
return (
|
||||
<Collapsible
|
||||
asChild
|
||||
defaultOpen={checkIsActive(href, item, true)}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 rtl:rotate-180' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='CollapsibleContent'>
|
||||
<SidebarMenuSub>
|
||||
{item.items.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={checkIsActive(href, subItem)}
|
||||
>
|
||||
<Link to={subItem.url} onClick={() => setOpenMobile(false)}>
|
||||
{subItem.icon && <subItem.icon />}
|
||||
<span>{subItem.title}</span>
|
||||
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuCollapsedDropdown({
|
||||
item,
|
||||
href,
|
||||
}: {
|
||||
item: NavCollapsible
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={checkIsActive(href, item)}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side='right' align='start' sideOffset={4}>
|
||||
<DropdownMenuLabel>
|
||||
{item.title} {item.badge ? `(${item.badge})` : ''}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{item.items.map((sub) => (
|
||||
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
|
||||
<Link
|
||||
to={sub.url}
|
||||
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
|
||||
>
|
||||
{sub.icon && <sub.icon />}
|
||||
<span className='max-w-52 text-wrap'>{sub.title}</span>
|
||||
{sub.badge && (
|
||||
<span className='ms-auto text-xs'>{sub.badge}</span>
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function checkIsActive(href: string, item: NavItem, mainNav = false) {
|
||||
return (
|
||||
href === item.url || // /endpint?search=param
|
||||
href.split('?')[0] === item.url || // endpoint
|
||||
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
|
||||
(mainNav &&
|
||||
href.split('/')[1] !== '' &&
|
||||
href.split('/')[1] === item?.url?.split('/')[1])
|
||||
)
|
||||
}
|
||||
144
src/components/layout/nav-user.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
BadgeCheck,
|
||||
ChevronsUpDown,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import useDialogState from '@/hooks/use-dialog-state'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { SignOutDialog } from '@/components/sign-out-dialog'
|
||||
|
||||
type NavUserProps = {
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
}
|
||||
|
||||
export function NavUser({ user }: NavUserProps) {
|
||||
const { isMobile } = useSidebar()
|
||||
const [open, setOpen] = useDialogState()
|
||||
const authUser = useAuthStore((state) => state.auth.user)
|
||||
|
||||
const currentUser = useMemo(
|
||||
() => ({
|
||||
name: authUser?.name || user.name,
|
||||
email: authUser?.email || user.email,
|
||||
avatar: user.avatar,
|
||||
}),
|
||||
[authUser, user.avatar, user.email, user.name]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
<Avatar className='h-8 w-8 rounded-lg'>
|
||||
<AvatarImage
|
||||
src={currentUser.avatar}
|
||||
alt={currentUser.name}
|
||||
/>
|
||||
<AvatarFallback className='rounded-lg bg-[#1f4b87] text-white'>
|
||||
{currentUser.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-start text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>
|
||||
{currentUser.name}
|
||||
</span>
|
||||
<span className='truncate text-xs'>{currentUser.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className='ms-auto size-4' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className='p-0 font-normal'>
|
||||
<div className='flex items-center gap-2 px-1 py-1.5 text-start text-sm'>
|
||||
<Avatar className='h-8 w-8 rounded-lg'>
|
||||
<AvatarImage
|
||||
src={currentUser.avatar}
|
||||
alt={currentUser.name}
|
||||
/>
|
||||
<AvatarFallback className='rounded-lg bg-[#1f4b87] text-white'>
|
||||
{currentUser.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-start text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>
|
||||
{currentUser.name}
|
||||
</span>
|
||||
<span className='truncate text-xs'>
|
||||
{currentUser.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/users'>
|
||||
<BadgeCheck />
|
||||
Kullanici listesi
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/roles'>
|
||||
<ShieldCheck />
|
||||
Rol ve yetkiler
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/users'>
|
||||
<Users />
|
||||
Erisim denetimi
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant='destructive'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<LogOut />
|
||||
Cikis yap
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
<SignOutDialog open={!!open} onOpenChange={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
49
src/components/layout/team-switcher.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react'
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import { Logo } from '@/assets/logo'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
type TeamSwitcherProps = {
|
||||
teams: {
|
||||
name: string
|
||||
logo: React.ElementType
|
||||
plan: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export function TeamSwitcher({ teams }: TeamSwitcherProps) {
|
||||
const [activeTeam, setActiveTeam] = React.useState(teams[0])
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveTeam(teams[0])
|
||||
}, [teams])
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='h-auto items-start rounded-2xl border border-sidebar-border/70 bg-[linear-gradient(180deg,rgba(15,36,71,0.98),rgba(10,26,53,0.98))] px-3 py-3 text-white hover:bg-[linear-gradient(180deg,rgba(15,36,71,0.98),rgba(10,26,53,0.98))]'
|
||||
>
|
||||
<div className='flex aspect-square size-10 items-center justify-center rounded-xl bg-white/10 text-[#d7b16d]'>
|
||||
<Logo className='size-5' />
|
||||
</div>
|
||||
<div className='grid flex-1 text-start text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{activeTeam.name}</span>
|
||||
<span className='truncate text-xs text-white/60'>
|
||||
{activeTeam.plan}
|
||||
</span>
|
||||
<span className='mt-2 inline-flex w-fit items-center gap-1 rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] font-semibold tracking-[0.2em] text-white/72 uppercase'>
|
||||
<ShieldCheck className='size-3' />
|
||||
Secure Panel
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
67
src/components/layout/top-nav.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Menu } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
type TopNavProps = React.HTMLAttributes<HTMLElement> & {
|
||||
links: {
|
||||
title: string
|
||||
href: string
|
||||
isActive: boolean
|
||||
disabled?: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
export function TopNav({ className, links, ...props }: TopNavProps) {
|
||||
return (
|
||||
<>
|
||||
<div className='lg:hidden'>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size='icon' variant='outline' className='md:size-7'>
|
||||
<Menu />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side='bottom' align='start'>
|
||||
{links.map(({ title, href, isActive, disabled }) => (
|
||||
<DropdownMenuItem key={`${title}-${href}`} asChild>
|
||||
<Link
|
||||
to={href}
|
||||
className={!isActive ? 'text-muted-foreground' : ''}
|
||||
disabled={disabled}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
'hidden items-center space-x-4 lg:flex lg:space-x-4 xl:space-x-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{links.map(({ title, href, isActive, disabled }) => (
|
||||
<Link
|
||||
key={`${title}-${href}`}
|
||||
to={href}
|
||||
disabled={disabled}
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${isActive ? '' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
44
src/components/layout/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { type LinkProps } from '@tanstack/react-router'
|
||||
|
||||
type User = {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
type Team = {
|
||||
name: string
|
||||
logo: React.ElementType
|
||||
plan: string
|
||||
}
|
||||
|
||||
type BaseNavItem = {
|
||||
title: string
|
||||
badge?: string
|
||||
icon?: React.ElementType
|
||||
}
|
||||
|
||||
type NavLink = BaseNavItem & {
|
||||
url: LinkProps['to'] | (string & {})
|
||||
items?: never
|
||||
}
|
||||
|
||||
type NavCollapsible = BaseNavItem & {
|
||||
items: (BaseNavItem & { url: LinkProps['to'] | (string & {}) })[]
|
||||
url?: never
|
||||
}
|
||||
|
||||
type NavItem = NavCollapsible | NavLink
|
||||
|
||||
type NavGroup = {
|
||||
title: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
type SidebarData = {
|
||||
user: User
|
||||
teams: Team[]
|
||||
navGroups: NavGroup[]
|
||||
}
|
||||
|
||||
export type { SidebarData, NavGroup, NavItem, NavCollapsible, NavLink }
|
||||
44
src/components/learn-more.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { type Root, type Content, type Trigger } from '@radix-ui/react-popover'
|
||||
import { CircleQuestionMark } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
type LearnMoreProps = React.ComponentProps<typeof Root> & {
|
||||
contentProps?: React.ComponentProps<typeof Content>
|
||||
triggerProps?: React.ComponentProps<typeof Trigger>
|
||||
}
|
||||
|
||||
export function LearnMore({
|
||||
children,
|
||||
contentProps,
|
||||
triggerProps,
|
||||
...props
|
||||
}: LearnMoreProps) {
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
{...triggerProps}
|
||||
className={cn('size-5 rounded-full', triggerProps?.className)}
|
||||
>
|
||||
<Button variant='outline' size='icon'>
|
||||
<span className='sr-only'>Learn more</span>
|
||||
<CircleQuestionMark className='size-4 [&>circle]:hidden' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='top'
|
||||
align='start'
|
||||
{...contentProps}
|
||||
className={cn('text-sm text-muted-foreground', contentProps?.className)}
|
||||
>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
84
src/components/long-text.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type LongTextProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
export function LongText({
|
||||
children,
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
}: LongTextProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [isOverflown, setIsOverflown] = useState(false)
|
||||
|
||||
// Use ref callback to check overflow when element is mounted
|
||||
const refCallback = (node: HTMLDivElement | null) => {
|
||||
ref.current = node
|
||||
if (node && checkOverflow(node)) {
|
||||
queueMicrotask(() => setIsOverflown(true))
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOverflown)
|
||||
return (
|
||||
<div ref={refCallback} className={cn('truncate', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='hidden sm:block'>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div ref={refCallback} className={cn('truncate', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className={contentClassName}>{children}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className='sm:hidden'>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div ref={refCallback} className={cn('truncate', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={cn('w-fit', contentClassName)}>
|
||||
<p>{children}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const checkOverflow = (textContainer: HTMLDivElement | null) => {
|
||||
if (textContainer) {
|
||||
return (
|
||||
textContainer.offsetHeight < textContainer.scrollHeight ||
|
||||
textContainer.offsetWidth < textContainer.scrollWidth
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
25
src/components/navigation-progress.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouterState } from '@tanstack/react-router'
|
||||
import LoadingBar, { type LoadingBarRef } from 'react-top-loading-bar'
|
||||
|
||||
export function NavigationProgress() {
|
||||
const ref = useRef<LoadingBarRef>(null)
|
||||
const state = useRouterState()
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'pending') {
|
||||
ref.current?.continuousStart()
|
||||
} else {
|
||||
ref.current?.complete()
|
||||
}
|
||||
}, [state.status])
|
||||
|
||||
return (
|
||||
<LoadingBar
|
||||
color='var(--muted-foreground)'
|
||||
ref={ref}
|
||||
shadow={true}
|
||||
height={2}
|
||||
/>
|
||||
)
|
||||
}
|
||||
42
src/components/password-input.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
type PasswordInputProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'type'
|
||||
> & {
|
||||
ref?: React.Ref<HTMLInputElement>
|
||||
}
|
||||
|
||||
export function PasswordInput({
|
||||
className,
|
||||
disabled,
|
||||
ref,
|
||||
...props
|
||||
}: PasswordInputProps) {
|
||||
const [showPassword, setShowPassword] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div className={cn('relative rounded-md', className)}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50'
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
disabled={disabled}
|
||||
className='absolute end-1 top-1/2 h-6 w-6 -translate-y-1/2 rounded-md text-muted-foreground'
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
>
|
||||
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
src/components/profile-dropdown.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ShieldCheck, Users } from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import useDialogState from '@/hooks/use-dialog-state'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SignOutDialog } from '@/components/sign-out-dialog'
|
||||
|
||||
export function ProfileDropdown() {
|
||||
const [open, setOpen] = useDialogState()
|
||||
const authUser = useAuthStore((state) => state.auth.user)
|
||||
const initials = (authUser?.name || 'BD').slice(0, 2).toUpperCase()
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
|
||||
<Avatar className='h-8 w-8'>
|
||||
<AvatarImage src='' alt={authUser?.name || 'Bogazici Admin'} />
|
||||
<AvatarFallback className='bg-[#1f4b87] text-white'>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className='w-56' align='end' forceMount>
|
||||
<DropdownMenuLabel className='font-normal'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<p className='text-sm leading-none font-medium'>
|
||||
{authUser?.name || 'Bogazici Yonetici'}
|
||||
</p>
|
||||
<p className='text-xs leading-none text-muted-foreground'>
|
||||
{authUser?.email || 'yonetim@bogazicidenizcilik.com.tr'}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/users'>
|
||||
Kullanicilar
|
||||
<DropdownMenuShortcut>
|
||||
<Users className='size-3.5' />
|
||||
</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/roles'>
|
||||
Roller
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/roles'>
|
||||
Yetki Matrisi
|
||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<ShieldCheck className='mr-2 size-4' />
|
||||
{authUser?.roles?.[0] || 'editor'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant='destructive' onClick={() => setOpen(true)}>
|
||||
Cikis yap
|
||||
<DropdownMenuShortcut className='text-current'>
|
||||
⇧⌘Q
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<SignOutDialog open={!!open} onOpenChange={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
178
src/components/rich-text-editor.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useEffect } from 'react'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import {
|
||||
Bold,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Redo2,
|
||||
Underline as UnderlineIcon,
|
||||
Undo2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type RichTextEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'İçeriği yazın...',
|
||||
}: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [2, 3],
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
content: value,
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
'min-h-52 rounded-b-2xl bg-background px-5 py-4 text-sm leading-7 text-foreground outline-none [&_.is-empty::before]:pointer-events-none [&_.is-empty::before]:float-left [&_.is-empty::before]:h-0 [&_.is-empty::before]:text-muted-foreground [&_.is-empty::before]:content-[attr(data-placeholder)] [&_blockquote]:border-s-4 [&_blockquote]:border-primary/30 [&_blockquote]:ps-4 [&_blockquote]:italic [&_h2]:mt-5 [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:mt-4 [&_h3]:text-lg [&_h3]:font-semibold [&_ol]:list-decimal [&_ol]:ps-6 [&_p]:my-3 [&_ul]:list-disc [&_ul]:ps-6',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: currentEditor }) => {
|
||||
onChange(currentEditor.getHTML())
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (editor.getHTML() !== value) {
|
||||
editor.commands.setContent(value || '<p></p>', {
|
||||
emitUpdate: false,
|
||||
})
|
||||
}
|
||||
}, [editor, value])
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toolbarButtons = [
|
||||
{
|
||||
label: 'Kalın',
|
||||
icon: Bold,
|
||||
isActive: editor.isActive('bold'),
|
||||
onClick: () => editor.chain().focus().toggleBold().run(),
|
||||
},
|
||||
{
|
||||
label: 'İtalik',
|
||||
icon: Italic,
|
||||
isActive: editor.isActive('italic'),
|
||||
onClick: () => editor.chain().focus().toggleItalic().run(),
|
||||
},
|
||||
{
|
||||
label: 'Altı çizili',
|
||||
icon: UnderlineIcon,
|
||||
isActive: editor.isActive('underline'),
|
||||
onClick: () => editor.chain().focus().toggleUnderline().run(),
|
||||
},
|
||||
{
|
||||
label: 'Başlık 2',
|
||||
icon: Heading2,
|
||||
isActive: editor.isActive('heading', { level: 2 }),
|
||||
onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
label: 'Başlık 3',
|
||||
icon: Heading3,
|
||||
isActive: editor.isActive('heading', { level: 3 }),
|
||||
onClick: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
label: 'Madde listesi',
|
||||
icon: List,
|
||||
isActive: editor.isActive('bulletList'),
|
||||
onClick: () => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
label: 'Numaralı liste',
|
||||
icon: ListOrdered,
|
||||
isActive: editor.isActive('orderedList'),
|
||||
onClick: () => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
label: 'Alıntı',
|
||||
icon: Quote,
|
||||
isActive: editor.isActive('blockquote'),
|
||||
onClick: () => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-2xl border border-border/70 bg-muted/10'>
|
||||
<div className='flex flex-wrap gap-2 border-b border-border/70 bg-muted/30 p-3'>
|
||||
{toolbarButtons.map((item) => {
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.label}
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className={cn(
|
||||
'rounded-lg border-border/70 bg-background/80',
|
||||
item.isActive &&
|
||||
'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<Icon className='size-4' />
|
||||
{item.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className='mx-1 hidden h-8 w-px bg-border md:block' />
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='rounded-lg border-border/70 bg-background/80'
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().chain().focus().undo().run()}
|
||||
>
|
||||
<Undo2 className='size-4' />
|
||||
Geri al
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='rounded-lg border-border/70 bg-background/80'
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().chain().focus().redo().run()}
|
||||
>
|
||||
<Redo2 className='size-4' />
|
||||
İleri al
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/components/search.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
type SearchProps = {
|
||||
className?: string
|
||||
type?: React.HTMLInputTypeAttribute
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function Search({
|
||||
className = '',
|
||||
placeholder = 'Search',
|
||||
}: SearchProps) {
|
||||
const { setOpen } = useSearch()
|
||||
return (
|
||||
<Button
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'group relative h-8 w-full flex-1 justify-start rounded-md bg-muted/25 text-sm font-normal text-muted-foreground shadow-none hover:bg-accent sm:w-40 sm:pe-12 md:flex-none lg:w-52 xl:w-64',
|
||||
className
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon
|
||||
aria-hidden='true'
|
||||
className='absolute start-1.5 top-1/2 -translate-y-1/2'
|
||||
size={16}
|
||||
/>
|
||||
<span className='ms-4'>{placeholder}</span>
|
||||
<kbd className='pointer-events-none absolute end-[0.3rem] top-[0.3rem] hidden h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 select-none group-hover:bg-accent sm:flex'>
|
||||
<span className='text-xs'>⌘</span>K
|
||||
</kbd>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
62
src/components/select-dropdown.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Loader } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FormControl } from '@/components/ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
type SelectDropdownProps = {
|
||||
onValueChange?: (value: string) => void
|
||||
defaultValue: string | undefined
|
||||
placeholder?: string
|
||||
isPending?: boolean
|
||||
items: { label: string; value: string }[] | undefined
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
isControlled?: boolean
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
isPending,
|
||||
items,
|
||||
placeholder,
|
||||
disabled,
|
||||
className = '',
|
||||
isControlled = false,
|
||||
}: SelectDropdownProps) {
|
||||
const defaultState = isControlled
|
||||
? { value: defaultValue, onValueChange }
|
||||
: { defaultValue, onValueChange }
|
||||
return (
|
||||
<Select {...defaultState}>
|
||||
<FormControl>
|
||||
<SelectTrigger disabled={disabled} className={cn(className)}>
|
||||
<SelectValue placeholder={placeholder ?? 'Select'} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{isPending ? (
|
||||
<SelectItem disabled value='loading' className='h-14'>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<Loader className='h-5 w-5 animate-spin' />
|
||||
{' '}
|
||||
Loading...
|
||||
</div>
|
||||
</SelectItem>
|
||||
) : (
|
||||
items?.map(({ label, value }) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
46
src/components/sign-out-dialog.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { logout } from '@/lib/api/auth'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
|
||||
interface SignOutDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
if (auth.accessToken) {
|
||||
await logout()
|
||||
}
|
||||
} finally {
|
||||
auth.reset()
|
||||
}
|
||||
|
||||
// Preserve current location for redirect after sign-in
|
||||
const currentPath = location.href
|
||||
navigate({
|
||||
to: '/sign-in',
|
||||
search: { redirect: currentPath },
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title='Cikis yap'
|
||||
desc='Oturumu kapatmak istediginize emin misiniz? Panele yeniden erismek icin tekrar giris yapmaniz gerekir.'
|
||||
confirmText='Cikis yap'
|
||||
destructive
|
||||
handleConfirm={handleSignOut}
|
||||
className='sm:max-w-sm'
|
||||
/>
|
||||
)
|
||||
}
|
||||
10
src/components/skip-to-main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export function SkipToMain() {
|
||||
return (
|
||||
<a
|
||||
className={`fixed start-44 z-999 -translate-y-52 bg-primary px-4 py-2 text-sm font-medium whitespace-nowrap text-primary-foreground opacity-95 shadow-sm transition hover:bg-primary/90 focus:translate-y-3 focus:transform focus-visible:ring-1 focus-visible:ring-ring`}
|
||||
href='#content'
|
||||
>
|
||||
Skip to Main
|
||||
</a>
|
||||
)
|
||||
}
|
||||
80
src/components/table-image-preview.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { ImageOff, X } from 'lucide-react'
|
||||
import { apiOrigin } from '@/lib/api/client'
|
||||
|
||||
function buildSrc(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
if (value.startsWith('http')) return value
|
||||
const clean = value.replace(/^\/+/, '').replace(/^storage\//, '')
|
||||
return `${apiOrigin}/${clean}`
|
||||
}
|
||||
|
||||
export function TableImagePreview({
|
||||
src,
|
||||
alt = '',
|
||||
}: {
|
||||
src: string | null | undefined
|
||||
alt?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const resolved = buildSrc(src)
|
||||
const backdropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [open])
|
||||
|
||||
if (!resolved) {
|
||||
return (
|
||||
<div className='flex h-10 w-14 items-center justify-center rounded-lg bg-muted text-muted-foreground'>
|
||||
<ImageOff className='size-4' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
className='group relative block h-10 w-14 shrink-0 overflow-hidden rounded-lg border border-border/50 bg-muted transition hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<img
|
||||
src={resolved}
|
||||
alt={alt}
|
||||
className='h-full w-full object-cover transition group-hover:scale-110'
|
||||
loading='lazy'
|
||||
/>
|
||||
<div className='absolute inset-0 bg-black/0 transition group-hover:bg-black/10' />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm'
|
||||
onClick={(e) => {
|
||||
if (e.target === backdropRef.current) setOpen(false)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='absolute top-4 right-4 z-10 rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X className='size-5' />
|
||||
</button>
|
||||
<img
|
||||
src={resolved}
|
||||
alt={alt}
|
||||
className='max-h-[85vh] max-w-[90vw] rounded-xl object-contain shadow-2xl'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
58
src/components/theme-switch.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Check, Moon, Sun } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
export function ThemeSwitch() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
/* Update theme-color meta tag
|
||||
* when theme is updated */
|
||||
useEffect(() => {
|
||||
const themeColor = theme === 'dark' ? '#020817' : '#fff'
|
||||
const metaThemeColor = document.querySelector("meta[name='theme-color']")
|
||||
if (metaThemeColor) metaThemeColor.setAttribute('content', themeColor)
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='icon' className='scale-95 rounded-full'>
|
||||
<Sun className='size-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90' />
|
||||
<Moon className='absolute size-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0' />
|
||||
<span className='sr-only'>Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
Light{' '}
|
||||
<Check
|
||||
size={14}
|
||||
className={cn('ms-auto', theme !== 'light' && 'hidden')}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
Dark
|
||||
<Check
|
||||
size={14}
|
||||
className={cn('ms-auto', theme !== 'dark' && 'hidden')}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
System
|
||||
<Check
|
||||
size={14}
|
||||
className={cn('ms-auto', theme !== 'system' && 'hidden')}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
154
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot='alert-dialog-overlay'
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot='alert-dialog-content'
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-dialog-header'
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-start', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-dialog-footer'
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot='alert-dialog-title'
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot='alert-dialog-description'
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
65
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert'
|
||||
role='alert'
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-title'
|
||||
className={cn(
|
||||
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-description'
|
||||
className={cn(
|
||||
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
50
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react'
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot='avatar'
|
||||
className={cn(
|
||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot='avatar-image'
|
||||
className={cn('aspect-square size-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot='avatar-fallback'
|
||||
className={cn(
|
||||
'flex size-full items-center justify-center rounded-full bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
45
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot='badge'
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot='button'
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
210
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from 'lucide-react'
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant']
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn(
|
||||
'flex gap-4 flex-col md:flex-row relative',
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
|
||||
nav: cn(
|
||||
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
'absolute bg-popover inset-0 opacity-0',
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
'select-none font-medium',
|
||||
captionLayout === 'label'
|
||||
? 'text-sm'
|
||||
: 'rounded-md ps-2 pe-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: 'w-full border-collapse',
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn('flex w-full mt-2', defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
'select-none w-(--cell-size)',
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
'text-[0.8rem] select-none text-muted-foreground',
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
'rounded-l-md bg-accent',
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||
today: cn(
|
||||
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
'text-muted-foreground aria-selected:text-muted-foreground',
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
'text-muted-foreground opacity-50',
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot='calendar'
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === 'right') {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn('size-4', className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className='flex size-(--cell-size) items-center justify-center text-center'>
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
91
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card'
|
||||
className={cn(
|
||||
'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-header'
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-title'
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-description'
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-action'
|
||||
className={cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-content'
|
||||
className={cn('px-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='card-footer'
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
29
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot='checkbox'
|
||||
className={cn(
|
||||
'peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot='checkbox-indicator'
|
||||
className='flex items-center justify-center text-current transition-none'
|
||||
>
|
||||
<CheckIcon className='size-3.5' />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
31
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot='collapsible' {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot='collapsible-trigger'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot='collapsible-content'
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||