Compare commits

...

57 Commits

Author SHA1 Message Date
sajjadtalkhabi b2f2f89181 fix: bugs
Deploy banu-front / deploy (push) Successful in 1m26s
2026-08-15 19:42:33 +03:30
Server Migration b64f528c0d fix: complete registration frontend flow
Deploy banu-front / deploy (push) Successful in 1m40s
2026-08-15 17:28:40 +03:30
Server Migration d94a96b25e fix: resolve education dashboard interface issues 2026-08-15 13:06:49 +03:30
sajjadtalkhabi af0149d722 fix: media recorder
Deploy banu-front / deploy (push) Successful in 1m45s
2026-08-15 13:01:18 +03:30
sajjadtalkhabi fec1da6ec8 fix: localize Yup validation messages
Deploy banu-front / deploy (push) Successful in 1m50s
2026-08-14 21:08:52 +03:30
Server Migration fe08fc46fb fix: bind session audio URL correctly
Deploy banu-front / deploy (push) Successful in 1m20s
2026-08-04 18:45:39 +00:00
Server Migration 3d005859ab fix: remove old-server frontend references
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:43:12 +00:00
Server Migration f13eb9bbb4 build: use reliable npm registry
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:38:11 +00:00
Server Migration fb0e4aa7f4 chore: bind frontend deployment to loopback
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:32:43 +00:00
Server Migration 94ac69d96a chore: migrate deployment to madresebanoo.ir
Deploy banu-front / deploy (push) Waiting to run
2026-08-04 18:32:19 +00:00
sajjadtalkhabi 39b295037e fix: bugs
Deploy banu-front / deploy (push) Successful in 2m18s
2026-08-01 10:34:21 +03:30
sajjadtalkhabi 31d100861b fix: bugs
Deploy banu-front / deploy (push) Failing after 23s
2026-07-30 18:56:55 +03:30
sajjadtalkhabi 6510432f41 Merge branch 'master' of ssh://109.122.252.86:2222/front-end/banu-front
Deploy banu-front / deploy (push) Successful in 10m31s
2026-07-30 18:53:59 +03:30
sajjadtalkhabi 8fefb4568b fix: bugs 2026-07-30 18:53:56 +03:30
root 32e97a7591 Revert "feat: update admin dashboard statistics"
Deploy banu-front / deploy (push) Failing after 1m24s
This reverts commit fe1fb6cfa4.
2026-07-30 14:16:10 +00:00
root fe1fb6cfa4 feat: update admin dashboard statistics
Deploy banu-front / deploy (push) Failing after 1m31s
2026-07-30 14:15:46 +00:00
sajjadtalkhabi bc9ce01d3f Merge branch 'master' of ssh://109.122.252.86:2222/front-end/banu-front
Deploy banu-front / deploy (push) Successful in 3m33s
2026-07-30 17:41:10 +03:30
sajjadtalkhabi 38051db180 fix: bugs 2026-07-30 17:40:41 +03:30
sajjadtalkhabi 36b747d267 fix: bugs 2026-07-30 17:38:27 +03:30
root c69a1babe5 fix: attach admin user avatars
Deploy banu-front / deploy (push) Successful in 4m35s
2026-07-30 13:55:30 +00:00
root 90cf147362 fix: allow images up to 5 MB
Deploy banu-front / deploy (push) Successful in 1m0s
2026-07-30 13:38:28 +00:00
sajjadtalkhabi e8b1e4add6 fix: missionary education
Deploy banu-front / deploy (push) Successful in 1m31s
2026-07-21 12:24:58 +03:30
sajjadtalkhabi 2a1f1b19d4 fix: responsive
Deploy banu-front / deploy (push) Successful in 1m33s
2026-07-13 12:15:44 +03:30
sajjadtalkhabi 8d2aa1c214 fix: router
Deploy banu-front / deploy (push) Successful in 1m38s
2026-07-12 12:16:12 +03:30
sajjadtalkhabi b592f520ab fix: dashboard
Deploy banu-front / deploy (push) Successful in 1m34s
2026-07-12 12:03:26 +03:30
sajjadtalkhabi dbc166f015 fix: bugs
Deploy banu-front / deploy (push) Successful in 1m27s
2026-07-09 13:50:49 +03:30
sajjadtalkhabi da34813b1d fix: bugs
Deploy banu-front / deploy (push) Successful in 1m28s
2026-07-09 13:25:59 +03:30
sajjadtalkhabi 38a396ba21 fix
Deploy banu-front / deploy (push) Successful in 1m27s
2026-07-07 17:36:43 +03:30
sajjadtalkhabi b9ef684b51 fix: bugs
Deploy banu-front / deploy (push) Successful in 1m22s
2026-07-02 19:09:13 +03:30
sajjadtalkhabi e700609236 fix
Deploy banu-front / deploy (push) Successful in 1m20s
2026-06-27 20:07:58 +03:30
sajjadtalkhabi 2c617258f4 fix: dashboard 2026-06-27 15:45:07 +03:30
sajjadtalkhabi a4432a30cb fix: mirror
Deploy banu-front / deploy (push) Successful in 1m27s
2026-06-12 01:01:45 +03:30
saji_kk 6b36ec8241 ci: read VITE env from Gitea action variables instead of hardcoding
Deploy banu-front / deploy (push) Failing after 44m27s
- deploy.yml now uses ${{ vars.VITE_API_BASE_URL }} / ${{ vars.VITE_USE_MOCKS }}
- Dockerfile drops hardcoded prod IP default for VITE_API_BASE_URL
2026-06-11 21:10:37 +00:00
sajjadtalkhabi 70d14cc119 fix: env
Deploy banu-front / deploy (push) Failing after 16m13s
2026-06-12 00:27:41 +03:30
sajjadtalkhabi 90aec01095 fix: dockerfile
Deploy banu-front / deploy (push) Failing after 20m28s
2026-06-12 00:23:27 +03:30
sajjadtalkhabi 4011c2f83b feat: education
Deploy banu-front / deploy (push) Failing after 6s
2026-06-11 21:09:12 +03:30
sajjadtalkhabi 020a47f735 fix: data
Deploy banu-front / deploy (push) Failing after 6s
2026-06-11 19:22:35 +03:30
sajjadtalkhabi a007db0d50 fix: user status
Deploy banu-front / deploy (push) Failing after 7s
2026-06-11 16:57:17 +03:30
sajjadtalkhabi 130266b416 fix: layout 2026-06-11 15:00:02 +03:30
sajjadtalkhabi b9fa65b84f fix: admin users
Deploy banu-front / deploy (push) Failing after 16s
2026-06-10 20:55:54 +03:30
sajjadtalkhabi b8ccbcc030 fix: tickets
Deploy banu-front / deploy (push) Failing after 16s
2026-06-05 19:42:09 +03:30
sajjadtalkhabi ea562aeefa fix: ticket 2026-06-05 18:43:02 +03:30
sajjadtalkhabi 1c665830b0 fix
Deploy banu-front / deploy (push) Successful in 1m19s
2026-06-05 14:41:38 +03:30
sajjadtalkhabi d42b7aaa7b fix: education
Deploy banu-front / deploy (push) Successful in 1m21s
2026-05-28 19:49:00 +03:30
sajjadtalkhabi 9660e3fe8f fix: linter
Deploy banu-front / deploy (push) Successful in 1m28s
2026-05-28 15:52:48 +03:30
sajjadtalkhabi 3f3c66a02d fix: test 2026-05-28 15:40:22 +03:30
sajjadtalkhabi fc0aa2586e ci: add gitea actions workflow for docker deploy
Deploy banu-front / deploy (push) Successful in 52s
Build banu-front image and replace container on port 8080 on push to master.
Runs on self-hosted host runner with docker socket access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:28:54 +03:30
sajjadtalkhabi 7ed6ec9ae1 fix: user changes 2026-05-28 13:34:30 +03:30
sajjadtalkhabi cb86920015 fix: admin courses 2026-05-27 14:47:19 +03:30
sajjadtalkhabi 7eb2984f5e fix: docker file 2026-05-21 18:01:25 +03:30
sajjadtalkhabi fc3de7269d fix: deploy 2026-05-21 17:59:29 +03:30
sajjadtalkhabi 494bd7254f fix: docker file 2026-05-21 17:49:31 +03:30
sajjadtalkhabi 35a0f2c04c fix 2026-05-21 16:08:36 +03:30
sajjadtalkhabi 74227d5021 fix 2026-05-21 15:51:32 +03:30
sajjadtalkhabi 8990ceaddd fix: design 2026-05-19 22:53:05 +03:30
sajjadtalkhabi c82701888a fix 2026-05-15 17:12:51 +03:30
sajjadtalkhabi b8ced45375 Fix 2026-05-13 03:06:59 +03:30
420 changed files with 29153 additions and 5598 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env.local
.env.*.local
-2
View File
@@ -1,2 +0,0 @@
VITE_API_BASE_URL=
VITE_USE_MOCKS=true
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=
VITE_USE_MOCKS=
+15 -6
View File
@@ -5,8 +5,14 @@ module.exports = {
node: true, node: true,
es2021: true, es2021: true,
}, },
plugins: ['prettier', 'unicorn', 'import'], plugins: ['prettier', 'unicorn', 'import', 'waterfall'],
ignorePatterns: ['node_modules/', 'dist/', 'build/', '*.min.js'], ignorePatterns: [
'node_modules/',
'dist/',
'build/',
'*.min.js',
'src/components/icons/icon-names.d.ts',
],
parserOptions: { parserOptions: {
ecmaVersion: 2021, ecmaVersion: 2021,
sourceType: 'module', sourceType: 'module',
@@ -37,7 +43,7 @@ module.exports = {
rules: { rules: {
'no-alert': 'error', 'no-alert': 'error',
'no-shadow': 'off', 'no-shadow': 'off',
'no-console': 'off', 'no-console': 'error',
'import/order': 'off', 'import/order': 'off',
'dot-notation': 'error', 'dot-notation': 'error',
'no-else-return': 'off', 'no-else-return': 'off',
@@ -71,7 +77,10 @@ module.exports = {
'unicorn/prefer-logical-operator-over-ternary': 'off', 'unicorn/prefer-logical-operator-over-ternary': 'off',
'unicorn/prefer-number-properties': 'off', 'unicorn/prefer-number-properties': 'off',
'unicorn/prefer-ternary': 'off', 'unicorn/prefer-ternary': 'off',
'no-unused-vars': 'off', 'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'waterfall/waterfall-imports': 'warn',
'waterfall/waterfall-requires': 'warn',
'waterfall/waterfall-objects': 'off',
'no-irregular-whitespace': 'off', 'no-irregular-whitespace': 'off',
'no-empty': 'off', 'no-empty': 'off',
'import/extensions': 'off', 'import/extensions': 'off',
@@ -80,9 +89,9 @@ module.exports = {
'vue/html-self-closing': 'off', 'vue/html-self-closing': 'off',
'vue/max-attributes-per-line': 'off', 'vue/max-attributes-per-line': 'off',
'vue/html-indent': 'off', 'vue/html-indent': 'off',
'vue/no-unused-components': 'off', 'vue/no-unused-components': 'error',
'vue/no-reserved-component-names': 'off', 'vue/no-reserved-component-names': 'off',
'vue/no-unused-vars': 'off', 'vue/no-unused-vars': 'error',
'vue/require-v-for-key': 'off', 'vue/require-v-for-key': 'off',
'vue/require-toggle-inside-transition': 'off', 'vue/require-toggle-inside-transition': 'off',
'vue/require-valid-default-prop': 'off', 'vue/require-valid-default-prop': 'off',
+51
View File
@@ -0,0 +1,51 @@
name: Deploy banu-front
on:
push:
branches: [master]
workflow_dispatch:
jobs:
deploy:
runs-on: host
env:
IMAGE: banu-front
CONTAINER: banu-front
PORT: '8080'
VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }}
VITE_USE_MOCKS: ${{ vars.VITE_USE_MOCKS }}
steps:
- name: Clone repository at current commit
run: |
BUILD_DIR="$(mktemp -d -t banu-front-XXXXXX)"
echo "BUILD_DIR=$BUILD_DIR" >> "$GITHUB_ENV"
git clone http://127.0.0.1:3000/front-end/banu-front.git "$BUILD_DIR"
git -C "$BUILD_DIR" checkout "${{ github.sha }}"
- name: Build Docker image
run: |
docker build \
--build-arg VITE_API_BASE_URL="$VITE_API_BASE_URL" \
--build-arg VITE_USE_MOCKS="$VITE_USE_MOCKS" \
-t "$IMAGE:${{ github.sha }}" \
-t "$IMAGE:latest" \
"$BUILD_DIR"
- name: Replace running container
run: |
docker rm -f "$CONTAINER" 2>/dev/null || true
docker run -d \
--name "$CONTAINER" \
--restart unless-stopped \
-p "127.0.0.1:$PORT:80" \
"$IMAGE:latest"
- name: Prune dangling images
run: docker image prune -f
- name: Cleanup build dir
if: always()
run: rm -rf "$BUILD_DIR"
- name: Show running container
run: docker ps --filter "name=$CONTAINER" --format "table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}"
+4
View File
@@ -0,0 +1,4 @@
{
"*.{js,vue}": "eslint --fix",
"*.scss": "prettier --write"
}
-1
View File
@@ -1,4 +1,3 @@
{ {
"recommendations": [ "recommendations": [
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
+34
View File
@@ -0,0 +1,34 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
# RUN npm config set registry https://mirror-npm.runflare.com
# RUN npm config set strict-ssl false
RUN npm config set registry https://registry.npmjs.org/ --global
RUN npm config set strict-ssl false
RUN npm ci
COPY . .
ARG VITE_API_BASE_URL
ARG VITE_USE_MOCKS=false
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_USE_MOCKS=$VITE_USE_MOCKS
RUN npm run build
# Stage 2: Serve
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
set -e
REPO="ssh://git@git.meshkee.com:222/sajjad.talkhabi/test.git"
APP_DIR="/root/banu-front"
IMAGE="banu-front"
CONTAINER="banu-front"
PORT="8080"
VITE_API_BASE_URL="//api.madresebanoo.ir/api"
echo "==> Pulling latest code..."
if [ -d "$APP_DIR/.git" ]; then
git -C "$APP_DIR" pull
else
git clone "$REPO" "$APP_DIR"
fi
echo "==> Building Docker image..."
docker build \
--build-arg VITE_API_BASE_URL="$VITE_API_BASE_URL" \
-t "$IMAGE" "$APP_DIR"
echo "==> Stopping old container..."
docker rm -f "$CONTAINER" 2>/dev/null || true
echo "==> Starting new container..."
docker run -d \
--name "$CONTAINER" \
--restart unless-stopped \
-p "$PORT:80" \
"$IMAGE"
echo "==> Done. App running at http://$(hostname -I | awk '{print $1}'):$PORT"
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
# Backend (Postman) vs Frontend — fields & endpoints to decide on
Source of truth: the Postman collection covering Terms, Courses, Sessions, Exams, Homeworks, Media (2026-05). This doc lists every place where backend and FE disagree on shape, plus FE-side concepts the backend doc has no slot for. Each item needs a product/design call before we wire it up.
Conventions in the rest of this doc:
- **B → FE** means the backend exposes a field/endpoint that the FE doesn't surface yet.
- **FE → B** means the FE shows/sends a field the backend doc doesn't accept.
- **shape diff** means both sides handle the concept but in different shapes (enum values, nesting, naming).
---
## Terms
### Endpoints
| Backend | FE today | Status |
| --- | --- | --- |
| `GET /terms?per_page&active_only` | `getTermsList` | aligned |
| `GET /terms/:id` | `showTerm` | aligned |
| `POST /terms` | `addNewTerm` | aligned |
| `PATCH /terms/:id` | `updateTerm` | aligned |
| `DELETE /terms/:id` | `deleteTerm` | aligned |
| — | `cloneTerm` (`POST /admin/terms/:id/clone`) | **FE-only; backend has nothing.** Decide: drop the clone button, or ask backend to add it. |
| — | `changeStatusTerm` (`POST /admin/terms/:id/status`) | **Use `PATCH /terms/:id` with `is_active`** — dedicated status endpoint dropped. |
| — | `listUserTerm`, `addUserTerm`, `removeUserTerm`, `changeLeaveStatus` | **FE-only.** Term-students subtab + leave toggle. Backend exposes nothing equivalent. Keep mock-only until backend adds. |
| — | `listCourseTerm`, `addCourseTerm`, `removeCourseTerm` | **FE-only.** Can be replaced by `GET /courses?term_id=` for the list; the attach/detach side has no backend equivalent (course `term_id` is set at create-time). |
### Fields
| Backend → FE | FE has it as | Notes |
| --- | --- | --- |
| `starts_at`, `ends_at` (ISO datetime) | `startDate`, `endDate` | Naming diff; we send `starts_at`/`ends_at` in the mock already — confirm the FE form should rename or keep an adapter. |
| `cover_url` (read) + `cover_media_id` (write) | `coverUrl` / `coverMediaId` | aligned in shape, just camelCase. |
| `created_at` | `createdAt` | aligned. |
| `is_active` | `isActive` | aligned. |
| `description` | `description` | aligned. |
**FE → B** (need decision):
- `studentsCount`, `coursesCount` — computed UI counts. Backend response doesn't include them. Either compute client-side or ask backend for `?include=counts`.
- `image` (UI alias of cover) — FE keeps this for a fallback. Backend response only has `cover_url`.
---
## Courses
### Endpoints
| Backend | FE today | Status |
| --- | --- | --- |
| `GET /courses?term_id&per_page` | `getCoursesList` | aligned (drop `/admin/` prefix in `endpoints.js`). |
| `GET /courses/:id` | `showCourse` | aligned. Backend response includes nested `term` and `teacher` — FE already reads `course.term` / `course.teacher`, good. |
| `POST /courses` | `addNewCourse` | aligned. Backend currently requires `term_id` (422 example), but per user instruction the standalone (template-tab) flow must allow `term_id: null`. **Decide:** ask backend to make `term_id` nullable, or refuse to submit until a term is picked. |
| `PATCH /courses/:id` | `updateCourse` | aligned. Used now for status toggle too (drops dedicated `/status` endpoint). |
| `DELETE /courses/:id` | `deleteCourse` | aligned. |
| — | `changeStatusCourse` (`/admin/courses/:id/status`) | **Dropped.** Use `PATCH /courses/:id { is_active }`. |
| — | `listCourseStudents`, `addCourseStudent`, `removeCourseStudent` | **FE-only.** CourseDetailsModal "students" tab + `AddCourseStudentModal`. Backend doesn't expose course-students; either add a sub-resource or remove the UI. Kept mock-only for now. |
| — | `listCourseSessions`, `attachCourseSession`, `detachCourseSession` | **FE-only.** CourseDetailsModal "sessions" tab can be served by `GET /sessions?course_id=`. The attach/detach side (M:N) has no backend equivalent — backend uses session.course_id (1:N). Kept mock-only for now; recommend swapping the list tab to the regular sessions query. |
### Fields
| Backend ↔ FE | Notes |
| --- | --- |
| `term_id``termId` | aligned. Nullable in CourseFormPage, required in AddOfferedCourseModal — schema reflects this. |
| `teacher_id``teacherId` | aligned. |
| `capacity``capacity` | aligned. |
| `is_active``isActive` | aligned. |
| `cover_media_id` (write) / `cover_url` (read) ⇄ `coverMediaId` / `coverUrl` | aligned. |
| `description``description` | aligned. |
| Nested `term`, `teacher` on show response | FE already reads. |
**FE → B** (need decision):
- `sessionsCount` — number-of-sessions field on the create form. Backend has nothing. Either compute server-side from related sessions, or drop the field.
- `prerequisites` (array of { courseId, course }) — backend has no prerequisite relation. Drop or ask backend for it.
- `contentType` (video/voice/text) + `contentMediaId` — single course-level content file. Backend treats files only as session media. Decide whether course-level content should move to "intro session" or stay a course concept.
- `image` (UI alias of cover_url) — kept as a fallback alongside `coverUrl`.
---
## Sessions
### Endpoints
| Backend | FE today | Status |
| --- | --- | --- |
| `GET /sessions?course_id&per_page` | `getSessionsList` | aligned. |
| `GET /sessions/:id` | `showSession` | aligned. |
| `POST /sessions` | `addNewSession` | aligned. |
| `PATCH /sessions/:id` | `updateSession` | aligned. Used for status toggle now. |
| `DELETE /sessions/:id` | `deleteSession` | aligned. |
| — | `changeStatusSession` (`/admin/sessions/:id/toggle-status`) | **Dropped.** Use PATCH with `is_active`. |
| — | `getSessionsAttendance` (`/admin/sessions/:sessionId/attendances`) | **FE-only.** `SessionAttendanceModal` depends on this. Kept mock-only. |
### Fields — biggest gap of all five resources
| Backend | FE today | Notes |
| --- | --- | --- |
| `type` enum: `online \| offline \| content` | `sessionType` enum: `in_person`, `online`, `video`, `audio`, `text`, `slide`, `pdf` | **Shape diff.** FE has 7 values; backend has 3. There's an existing `SESSION_TYPE_TO_SPEC` mapper in `services/mock/fixtures/admin-sessions.js`. Decide whether the FE keeps the richer 7-value enum (and we map down to backend's 3) or collapses. |
| `starts_at`, `location`, `link` | All three live **inside** `form.sessionConfig.*` plus also derived to top-level `startsAt` / `location` / `link` in the mock | **Structural diff.** Backend wants flat fields; FE form nests them under `sessionConfig` keyed by `sessionType`. The mock derives top-level from `sessionConfig.*` for show payloads. Decide whether the FE form should flatten the schema to match backend or keep the conditional-by-type config UI. |
| `media_ids[]` (write) / `media[]` (read with `collection_name`, `file_name`, `mime_type`, `file_size`, `url`, `download_url`) | `materials[]` with `{ fileId, isRequired, type, title, order }` | **Shape diff.** Backend's media rows are typed by upload-purpose (video/voice/pdf/slide/attachment); FE has its own `type` enum. Decide which shape the FE keeps. |
| — | `durationMinutes`, `order`, `sessionConfig.minWatchedPercent`, `sessionConfig.minReadPercent`, `sessionConfig.mustCompleteBeforeNext`, `sessionConfig.platform` | **FE → B**, all UI-only fields. Backend has nothing equivalent. Drop, move into a `metadata` JSON, or ask backend to add. |
| — | `usedInTerms` | UI-only count, no backend. |
| — | `image` (vs `media`) | UI-only thumbnail; backend doesn't separate. |
---
## Exams
### Endpoints
| Backend | FE today | Status |
| --- | --- | --- |
| `GET /exams/:id` (with questions+options) | `showExam` | aligned. |
| `POST /exams` | `addNewExam` | aligned. |
| `PATCH /exams/:id` | `updateExam` | aligned. |
| `DELETE /exams/:id` | `deleteExam` | aligned. |
| `POST /exams/:examId/questions` | — | **Backend → FE.** New endpoint. Today the FE submits the whole question list inside the exam create/update payload. Decide whether to keep "all-in-one" submission (and ask backend to accept it) or switch to add-questions-after-create. |
| `POST /questions/:questionId/options` | — | **Backend → FE.** Same as above — backend lets you add options one at a time. FE today bundles all options with the question. |
| `POST /exams/:examId/submit` | — (student-side feature) | **Backend → FE.** Student-side; not in current admin UI. |
| — | `getExamsList` (`/admin/exams`) | **FE-only.** ExamsListPage depends on it. Kept mock-only; ask backend to add a list endpoint. |
| — | `getExamParticipants`, `showExamParticipant` | **FE-only.** ExamParticipantsModal + ExamParticipantDetailsModal depend on these. Kept mock-only. |
### Fields
| Backend ↔ FE | Notes |
| --- | --- | --- |
| `session_id``sessionId` | aligned. |
| `title``title` | aligned. |
| `description``description` | aligned. |
| `pass_score``passingScore` | aligned (naming diff). |
| `is_active` | **Backend → FE.** Exam form has no active toggle. Decide whether to add it. |
| Backend question shape: `{ question_text, position, options: [{ option_text, is_correct }] }` | FE: `{ title, score, correctAnswerId, answers: [{ id, title }] }` | **Shape diff.** Backend hides `is_correct` from public reads (only on add). FE concept of `score` (per-question weighting) has no backend slot. Decide: keep FE scoring (ask backend to store) or drop. |
**FE → B** (need decision):
- `durationMinutes` — no backend slot.
- `randomize` — no backend slot.
- `endDate` / `startDate` — exam validity window, no backend slot.
- `usedInTerms` — derived UI count.
---
## Homeworks (FE calls them "assignments")
### Endpoints
| Backend | FE today | Status |
| --- | --- | --- |
| `POST /homeworks` | `addNewAssignment` | aligned (URL renamed to `/homeworks`; FE key name kept). |
| `PATCH /homeworks/:id` | `updateAssignment` | aligned. |
| `DELETE /homeworks/:id` | `deleteAssignment` | aligned. |
| `POST /homeworks/:homeworkId/submit` (student) | — | **Backend → FE.** Student submit, not in admin UI yet. |
| `PATCH /homework-submissions/:submissionId/review` | `reviewAssignmentSubmission` | aligned (URL renamed). |
| — | `getAssignmentsList` | **FE-only.** AssignmentsListPage depends on it. Kept mock-only. |
| — | `showAssignment`, `getAssignmentSubmissions`, `showAssignmentSubmission` | **FE-only.** Detail + submissions list — kept mock-only. |
### Fields
| Backend ↔ FE | Notes |
| --- | --- | --- |
| `session_id``sessionId` | aligned. |
| `title`, `description` | aligned. |
| `deadline` (single datetime) | FE: `startDate` + `endDate` + computed `durationDays` | **Shape diff.** Backend has one deadline; FE has a window. Decide: drop start/end and use single deadline, or ask backend to add a window. |
| `is_active` | **Backend → FE.** FE form has no active toggle. |
| Submission `status`: `accepted \| denied` | FE: `pending \| approved \| rejected \| needs_revision` | **Shape diff.** Backend has two states; FE has four. The FE `pending/needs_revision` have no backend slot. |
| Submission `media_id` (single) | FE `attachments[]` (multiple) | **Shape diff.** Backend allows one file per submission; FE expects many. |
| Submission `teacher_feedback``reviewerNote` | naming diff. |
| Submission `reviewed_at` (read) | — | Backend provides; FE doesn't surface. |
**FE → B** (need decision):
- `priority` enum (`mandatory \| optional`) — no backend slot.
- `submissionsCount` — derived count, no backend slot.
---
## Media
### Endpoints
| Backend | FE today | Status |
| --- | --- | --- |
| `POST /media` (multipart with `purpose` + `file`) | `uploadMedia` | aligned URL & method. |
| `GET /media/:id/download` | — | **Backend → FE.** Add as `downloadMedia`. |
| `DELETE /media/:id` | — | **Backend → FE.** Add as `deleteMedia`. |
### Fields
- Backend response: `{ id, collection_name, file_name, mime_type, file_size, url, download_url }`. FE today reads `payload.id`, `payload.url`, sometimes `payload.uploadId` (a now-stale field). **Cleanup needed:** drop `uploadId`, use `id` everywhere.
- Backend `purpose` enum: `avatar | cover | video | voice | pdf | slide | attachment | homework_file`. FE upload calls currently hardcode `purpose: 'cover'` or `purpose: 'content'`. **`content` is not in the backend enum.** Decide which of the backend purposes each FE uploader should send (e.g., session video → `video`, course PDF → `pdf`, homework upload → `homework_file`).
- Pending media TTL: backend deletes unreferenced pending uploads after 24h. FE doesn't track this; if a user uploads a cover, abandons the form, and comes back next day, the `cover_media_id` reference will 404 on submit. Document the failure mode.
---
## Cross-cutting
### Snake_case vs camelCase
Backend wire format is snake_case throughout. FE today reads camelCase (e.g., the mock returns `coverUrl`, `isActive`). When the real backend lands, the FE will either need:
- a HTTP-layer transformer (camelize on response, snake_case on request), or
- camelCase field aliases on the backend serializer.
Decide before swapping the mock for the real API. Affects every screen.
### `/admin/` URL prefix
Backend doc has none — endpoints live at `/terms`, `/courses`, etc. The FE previously had `/admin/courses`, `/admin/sessions`, `/admin/exams`, `/admin/assignments`. **Aligned to backend (prefix dropped).** Auth context (admin role) is implicit in the token, not the URL.
### Sub-features that depend on missing backend endpoints
UI screens that work today against the mock but have no backend equivalent in the current doc (kept mock-only with TODOs in `endpoints.js`):
- Term: clone term, term status toggle, students-in-term subtab, leave toggle, courses-in-term subtab (could swap to `GET /courses?term_id=`)
- Course: status toggle (swapped to PATCH), students-in-course subtab, attach/detach sessions (M:N), `AddCourseStudentModal`, `AddSessionToCourseModal`
- Session: status toggle (swapped to PATCH), attendance roster
- Exam: list page, participants list, participant detail
- Assignment/Homework: list page, detail show, submissions list, submission detail
For each of these we need to either (a) ask backend to expose the endpoint, or (b) drop the UI surface.
+2 -2
View File
@@ -1,10 +1,10 @@
<!doctype html> <!DOCTYPE html>
<html lang="fa" dir="rtl"> <html lang="fa" dir="rtl">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Banu</title> <title>مدرسه بانو</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+2
View File
@@ -1,9 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"checkJs": false,
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]
} }
}, },
"include": ["src/**/*", "src/**/*.d.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback — all routes go to index.html
location / {
try_files $uri $uri/ /index.html;
}
}
+964
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -11,6 +11,7 @@
"lint:fix": "eslint . --ext .js,.vue --fix --max-warnings=0", "lint:fix": "eslint . --ext .js,.vue --fix --max-warnings=0",
"lint:css:check": "stylelint \"src/**/*.{css,scss,vue}\" --max-warnings=0", "lint:css:check": "stylelint \"src/**/*.{css,scss,vue}\" --max-warnings=0",
"lint:css:fix": "stylelint \"src/**/*.{css,scss,vue}\" --fix --max-warnings=0", "lint:css:fix": "stylelint \"src/**/*.{css,scss,vue}\" --fix --max-warnings=0",
"prepare": "husky install",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check ." "format:check": "prettier --check ."
}, },
@@ -18,9 +19,14 @@
"@floating-ui/dom": "^1.7.6", "@floating-ui/dom": "^1.7.6",
"@tanstack/vue-query": "^5.62.2", "@tanstack/vue-query": "^5.62.2",
"@tanstack/vue-query-devtools": "^5.62.2", "@tanstack/vue-query-devtools": "^5.62.2",
"@tinymce/tinymce-vue": "^4.0.7",
"axios": "^1.12.2", "axios": "^1.12.2",
"html2pdf.js": "^0.14.0",
"husky": "^8.0.0",
"jalaali-js": "^1.2.8",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"tinymce": "^8.5.0",
"vue": "^3.5.21", "vue": "^3.5.21",
"vue-advanced-cropper": "^2.8.9", "vue-advanced-cropper": "^2.8.9",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
@@ -36,6 +42,8 @@
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unicorn": "^55.0.0", "eslint-plugin-unicorn": "^55.0.0",
"eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue": "^9.15.1",
"eslint-plugin-waterfall": "^1.0.1",
"lint-staged": "^15.5.2",
"postcss-html": "^1.8.0", "postcss-html": "^1.8.0",
"prettier": "2.8.8", "prettier": "2.8.8",
"sass": "^1.83.0", "sass": "^1.83.0",
+52
View File
@@ -0,0 +1,52 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ICONS_DIR = path.resolve(__dirname, '../src/assets/icons')
const OUTPUT_FILE = path.resolve(__dirname, '../src/components/icons/icon-names.d.ts')
const readNames = () =>
fs
.readdirSync(ICONS_DIR)
.filter((f) => f.endsWith('.svg'))
.map((f) => f.replace(/\.svg$/, ''))
.sort()
const buildContent = (names) => {
const union = names.length > 0 ? names.map((n) => ` | '${n}'`).join('\n') : ' | never'
return `// AUTO-GENERATED — do not edit by hand.
// Regenerated whenever src/assets/icons/*.svg changes (via scripts/vite-icon-names.js).
export type IconName =
${union}
export const iconNames: readonly IconName[]
`
}
const writeIfChanged = () => {
const content = buildContent(readNames())
const prev = fs.existsSync(OUTPUT_FILE) ? fs.readFileSync(OUTPUT_FILE, 'utf8') : ''
if (prev !== content) fs.writeFileSync(OUTPUT_FILE, content)
}
export default function viteIconNames() {
return {
name: 'vite-icon-names',
buildStart() {
writeIfChanged()
},
configureServer(server) {
writeIfChanged()
server.watcher.add(path.join(ICONS_DIR, '*.svg'))
const handler = (file) => {
if (file.startsWith(ICONS_DIR) && file.endsWith('.svg')) writeIfChanged()
}
server.watcher.on('add', handler)
server.watcher.on('unlink', handler)
},
}
}
export { writeIfChanged as generateIconNames }
+34 -8
View File
@@ -1,20 +1,24 @@
<template> <template>
<component :is="currentLayout" /> <component v-if="isReady" :is="currentLayout" />
<ConfirmModal /> <ConfirmModal />
<VueQueryDevtools /> <VueQueryDevtools />
</template> </template>
<script setup> <script setup>
import { computed, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { VueQueryDevtools } from '@tanstack/vue-query-devtools' import { useAuthStore } from '@/store/auth'
import ConfirmModal from '@/components/ConfirmModal.vue'
import PublicLayout from '@/layouts/PublicLayout.vue'
import AdminLayout from '@/layouts/AdminLayout.vue'
import StudentLayout from '@/layouts/StudentLayout.vue'
import { useModalStore } from '@/store/modal' import { useModalStore } from '@/store/modal'
import AdminLayout from '@/layouts/AdminLayout.vue'
import { useGetMeQuery } from '@/services/query/auth'
import PublicLayout from '@/layouts/PublicLayout.vue'
import StudentLayout from '@/layouts/StudentLayout.vue'
import ConfirmModal from '@/components/ConfirmModal.vue'
import CounselorLayout from '@/layouts/CounselorLayout.vue'
import { tokenService } from '@/services/api/token-service'
import MissionaryLayout from '@/layouts/MissionaryLayout.vue'
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
const route = useRoute() const route = useRoute()
@@ -22,10 +26,32 @@ const layouts = {
public: PublicLayout, public: PublicLayout,
admin: AdminLayout, admin: AdminLayout,
student: StudentLayout, student: StudentLayout,
counselor: CounselorLayout,
missionary: MissionaryLayout,
} }
const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout) const currentLayout = computed(() => layouts[route.meta?.layout] || PublicLayout)
const authStore = useAuthStore()
const hasToken = computed(() => tokenService.hasToken())
const {
data: me,
isLoading: isMeLoading,
isFetched: isMeFetched,
} = useGetMeQuery({ enabled: hasToken })
watch(
me,
(value) => {
if (value) authStore.setUser(value)
},
{ immediate: true }
)
const isReady = computed(() => !hasToken.value || isMeFetched.value || !isMeLoading.value)
const { modals } = storeToRefs(useModalStore()) const { modals } = storeToRefs(useModalStore())
watch( watch(
() => modals.value.length, () => modals.value.length,
+12
View File
@@ -0,0 +1,12 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="11.625" stroke="#EEEEEE" stroke-width="0.75"/>
<g clip-path="url(#clip0_1_21196)">
<path d="M15.375 15.375L8.625 8.625" stroke="#B2B2B2" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.1094 8.625H8.625V14.1094" stroke="#B2B2B2" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_21196">
<rect width="13.5" height="13.5" fill="white" transform="translate(5.25 5.25)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 604 B

+7
View File
@@ -0,0 +1,7 @@
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<path d="M5.625 10.625H9.375"/>
<path d="M5.625 8.125H9.375"/>
<path d="M15.3125 13.125C17.211 13.125 18.75 11.586 18.75 9.6875C18.75 7.78902 17.211 6.25 15.3125 6.25C13.414 6.25 11.875 7.78902 11.875 9.6875C11.875 11.586 13.414 13.125 15.3125 13.125Z"/>
<path d="M13.125 15H3.125C2.95924 15 2.80027 14.9342 2.68306 14.8169C2.56585 14.6997 2.5 14.5408 2.5 14.375V4.375C2.5 4.20924 2.56585 4.05027 2.68306 3.93306C2.80027 3.81585 2.95924 3.75 3.125 3.75H16.875C17.0408 3.75 17.1997 3.81585 17.3169 3.93306C17.4342 4.05027 17.5 4.20924 17.5 4.375V7.03594"/>
<path d="M13.125 12.3389V17.4998L15.3125 16.2498L17.5 17.4998V12.3389"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<g clip-path="url(#clip0_1_32404)">
<path d="M12 12.375C12.6213 12.375 13.125 11.8713 13.125 11.25C13.125 10.6287 12.6213 10.125 12 10.125C11.3787 10.125 10.875 10.6287 10.875 11.25C10.875 11.8713 11.3787 12.375 12 12.375Z" fill="#B3B3B3"/>
<path d="M7.875 12.375C8.49632 12.375 9 11.8713 9 11.25C9 10.6287 8.49632 10.125 7.875 10.125C7.25368 10.125 6.75 10.6287 6.75 11.25C6.75 11.8713 7.25368 12.375 7.875 12.375Z" fill="#B3B3B3"/>
<path d="M16.125 12.375C16.7463 12.375 17.25 11.8713 17.25 11.25C17.25 10.6287 16.7463 10.125 16.125 10.125C15.5037 10.125 15 10.6287 15 11.25C15 11.8713 15.5037 12.375 16.125 12.375Z" fill="#B3B3B3"/>
<path d="M9.85031 18L11.3503 20.625C11.4159 20.74 11.5107 20.8356 11.6251 20.9021C11.7395 20.9685 11.8695 21.0036 12.0019 21.0036C12.1342 21.0036 12.2642 20.9685 12.3787 20.9021C12.4931 20.8356 12.5879 20.74 12.6534 20.625L14.1534 18H20.25C20.4489 18 20.6397 17.921 20.7803 17.7803C20.921 17.6397 21 17.4489 21 17.25V5.25C21 5.05109 20.921 4.86032 20.7803 4.71967C20.6397 4.57902 20.4489 4.5 20.25 4.5H3.75C3.55109 4.5 3.36032 4.57902 3.21967 4.71967C3.07902 4.86032 3 5.05109 3 5.25V17.25C3 17.4489 3.07902 17.6397 3.21967 17.7803C3.36032 17.921 3.55109 18 3.75 18H9.85031Z" stroke="#B3B3B3" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_32404">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 8.75H12.8125"/>
<path d="M7.5 11.25H12.8125"/>
<path d="M10.3125 16.875H3.75C3.58424 16.875 3.42527 16.8092 3.30806 16.6919C3.19085 16.5747 3.125 16.4158 3.125 16.25V9.6875C3.125 7.78126 3.88225 5.95309 5.23017 4.60517C6.57809 3.25725 8.40626 2.5 10.3125 2.5C11.2564 2.5 12.191 2.68591 13.063 3.04712C13.9351 3.40832 14.7274 3.93775 15.3948 4.60517C16.0623 5.27259 16.5917 6.06493 16.9529 6.93696C17.3141 7.80899 17.5 8.74362 17.5 9.6875C17.5 10.6314 17.3141 11.566 16.9529 12.438C16.5917 13.3101 16.0623 14.1024 15.3948 14.7698C14.7274 15.4373 13.9351 15.9667 13.063 16.3279C12.191 16.6891 11.2564 16.875 10.3125 16.875Z"/>
</svg>

After

Width:  |  Height:  |  Size: 794 B

+11
View File
@@ -0,0 +1,11 @@
<svg viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_24766)">
<path d="M4.64062 7.17188L5.90625 8.4375L8.85938 5.48438" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.75 11.8125C9.54594 11.8125 11.8125 9.54594 11.8125 6.75C11.8125 3.95406 9.54594 1.6875 6.75 1.6875C3.95406 1.6875 1.6875 3.95406 1.6875 6.75C1.6875 9.54594 3.95406 11.8125 6.75 11.8125Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_24766">
<rect width="13.5" height="13.5" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 655 B

+11
View File
@@ -0,0 +1,11 @@
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_21191)">
<path d="M6.5 11.375C9.19239 11.375 11.375 9.19239 11.375 6.5C11.375 3.80761 9.19239 1.625 6.5 1.625C3.80761 1.625 1.625 3.80761 1.625 6.5C1.625 9.19239 3.80761 11.375 6.5 11.375Z" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.5 3.65625V6.5H9.34375" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_21191">
<rect width="12.75" height="12.75" fill="white" transform="scale(1.01961)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 676 B

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 7.5V15.625C17.5 15.7908 17.4342 15.9497 17.3169 16.0669C17.1997 16.1842 17.0408 16.25 16.875 16.25H3.125C2.95924 16.25 2.80027 16.1842 2.68306 16.0669C2.56585 15.9497 2.5 15.7908 2.5 15.625V7.5L10 2.5L17.5 7.5Z"/>
<path d="M8.63711 11.875L2.69336 16.0734"/>
<path d="M17.307 16.0734L11.3633 11.875"/>
<path d="M17.5 7.5L11.3641 11.875H8.63672L2.5 7.5"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<path d="M9.375 16.25H3.125C2.95924 16.25 2.80027 16.1842 2.68306 16.0669C2.56585 15.9497 2.5 15.7908 2.5 15.625V5C2.5 4.83424 2.56585 4.67527 2.68306 4.55806C2.80027 4.44085 2.95924 4.375 3.125 4.375H7.29141C7.42664 4.375 7.55822 4.41886 7.66641 4.5L10 6.25H16.875C17.0408 6.25 17.1997 6.31585 17.3169 6.43306C17.4342 6.55027 17.5 6.70924 17.5 6.875V9.375"/>
<path d="M14.375 15.5109L16.693 16.875L16.0625 14.3289L18.125 12.6258L15.418 12.4172L14.375 10L13.332 12.4172L10.625 12.6258L12.6875 14.3289L12.057 16.875L14.375 15.5109Z"/>
</svg>

After

Width:  |  Height:  |  Size: 687 B

+5
View File
@@ -0,0 +1,5 @@
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<path d="M0.625 7.5L10 2.5L19.375 7.5L10 12.5L0.625 7.5Z"/>
<path d="M10 7.5L14.375 9.83359V18.75"/>
<path d="M16.875 8.83398V12.9918C16.8754 13.1445 16.8198 13.2921 16.7188 13.4066C15.8359 14.3902 13.6797 16.2504 10 16.2504C6.32031 16.2504 4.16563 14.3902 3.28125 13.4066C3.18023 13.2921 3.12465 13.1445 3.125 12.9918V8.83398"/>
</svg>

After

Width:  |  Height:  |  Size: 485 B

+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_32401)">
<path d="M12 21C12 21 2.25 15.75 2.25 9.5625C2.25 8.21984 2.78337 6.93217 3.73277 5.98277C4.68217 5.03337 5.96984 4.5 7.3125 4.5C9.43031 4.5 11.2444 5.65406 12 7.5C12.7556 5.65406 14.5697 4.5 16.6875 4.5C18.0302 4.5 19.3178 5.03337 20.2672 5.98277C21.2166 6.93217 21.75 8.21984 21.75 9.5625C21.75 15.75 12 21 12 21Z" stroke="#B3B3B3" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_32401">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 625 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<path d="M12.4258 5.68491L13.3926 4.71811C13.7731 4.3375 14.2248 4.03558 14.722 3.82959C15.2192 3.6236 15.7521 3.51758 16.2903 3.51758C16.8285 3.51758 17.3614 3.6236 17.8586 3.82959C18.3558 4.03558 18.8076 4.3375 19.1881 4.71811C19.5687 5.09861 19.8706 5.55036 20.0766 6.04756C20.2826 6.54477 20.3886 7.07768 20.3886 7.61586C20.3886 8.15405 20.2826 8.68696 20.0766 9.18416C19.8706 9.68136 19.5687 10.1331 19.1881 10.5136L17.0453 12.6564L16.1365 13.5652C15.7556 13.9462 15.3032 14.2483 14.8054 14.4544C14.3076 14.6604 13.774 14.7662 13.2352 14.7658C12.6964 14.7654 12.163 14.6587 11.6655 14.452C11.168 14.2452 10.7161 13.9424 10.3357 13.5608C9.94202 13.1672 9.6329 12.6973 9.42735 12.18C9.2218 11.6626 9.12416 11.1087 9.14042 10.5523" stroke="#DDDDDD" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.0742 16.8152L9.10745 17.782C8.7265 18.163 8.27417 18.4651 7.77633 18.6711C7.2785 18.8772 6.74492 18.983 6.20614 18.9826C5.66737 18.9822 5.13395 18.8755 4.63643 18.6688C4.1389 18.462 3.68703 18.1592 3.30667 17.7776C2.54025 17.0085 2.11034 15.9667 2.11133 14.8809C2.11232 13.7952 2.54412 12.7542 3.31194 11.9865L6.3635 8.93491C6.744 8.5543 7.19575 8.25238 7.69296 8.04639C8.19016 7.8404 8.72307 7.73438 9.26126 7.73438C9.79944 7.73438 10.3324 7.8404 10.8296 8.04639C11.3268 8.25238 11.7785 8.5543 12.159 8.93491C12.5543 9.32844 12.8648 9.79882 13.0713 10.317C13.2778 10.8352 13.3759 11.3902 13.3596 11.9478" stroke="#DDDDDD" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none">
<g clip-path="url(#clip0_1_33264)">
<path d="M5.625 4.21875C6.40165 4.21875 7.03125 3.58915 7.03125 2.8125C7.03125 2.03585 6.40165 1.40625 5.625 1.40625C4.84835 1.40625 4.21875 2.03585 4.21875 2.8125C4.21875 3.58915 4.84835 4.21875 5.625 4.21875Z" stroke="#989898" stroke-width="0.5775" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.625 4.21875V7.73438" stroke="#989898" stroke-width="0.5775" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.38281 6.11133C9.03428 6.375 10.1953 7.00298 10.1953 7.73423C10.1953 8.70498 8.14922 9.49204 5.625 9.49204C3.10078 9.49204 1.05469 8.70498 1.05469 7.73423C1.05469 7.00298 2.21572 6.37588 3.86719 6.11133" stroke="#989898" stroke-width="0.5775" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_33264">
<rect width="11.25" height="11.25" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 952 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<g clip-path="url(#clip0_1_27293)">
<path d="M14.7656 5.625C14.7656 3.68337 13.1916 2.10938 11.25 2.10938C9.30837 2.10938 7.73438 3.68337 7.73438 5.625V11.25C7.73438 13.1916 9.30837 14.7656 11.25 14.7656C13.1916 14.7656 14.7656 13.1916 14.7656 11.25V5.625Z" stroke="#A7A7A7" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.25 17.5781V21.0938" stroke="#A7A7A7" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.5781 11.25C17.5781 12.9283 16.9114 14.5379 15.7247 15.7247C14.5379 16.9114 12.9283 17.5781 11.25 17.5781C9.57168 17.5781 7.96209 16.9114 6.77534 15.7247C5.58859 14.5379 4.92188 12.9283 4.92188 11.25" stroke="#A7A7A7" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_27293">
<rect width="22.5" height="22.5" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 955 B

+11
View File
@@ -0,0 +1,11 @@
<svg width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2_135332)">
<path d="M4.21875 16.875L18.2813 16.875C19.0579 16.875 19.6875 16.2454 19.6875 15.4688L19.6875 5.625C19.6875 4.84835 19.0579 4.21875 18.2813 4.21875L4.21875 4.21875C3.4421 4.21875 2.8125 4.84835 2.8125 5.625L2.8125 15.4687C2.8125 16.2454 3.4421 16.875 4.21875 16.875Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.0625 19.6875L8.4375 19.6875" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_2_135332">
<rect width="22.5" height="22.5" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 746 B

+1 -1
View File
@@ -1,3 +1,3 @@
<svg width="36" height="35" viewBox="0 0 36 35" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 36 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.3488 15.0762C22.7483 15.0762 23.0857 14.9392 23.3611 14.6654C23.6365 14.3918 23.7742 14.0594 23.7742 13.6681C23.7742 13.2771 23.6343 12.9469 23.3545 12.6773C23.075 12.4078 22.7353 12.273 22.3356 12.273C21.9361 12.273 21.5986 12.41 21.3232 12.6838C21.0479 12.9574 20.9102 13.2898 20.9102 13.6811C20.9102 14.0721 21.0501 14.4023 21.3299 14.6719C21.6094 14.9414 21.9491 15.0762 22.3488 15.0762ZM12.9121 15.0762C13.3116 15.0762 13.649 14.9392 13.9244 14.6654C14.1998 14.3918 14.3375 14.0594 14.3375 13.6681C14.3375 13.2771 14.1976 12.9469 13.9178 12.6773C13.6383 12.4078 13.2986 12.273 12.8989 12.273C12.4994 12.273 12.1619 12.41 11.8865 12.6838C11.6111 12.9574 11.4734 13.2898 11.4734 13.6811C11.4734 14.0721 11.6133 14.4023 11.8931 14.6719C12.1727 14.9414 12.5123 15.0762 12.9121 15.0762ZM17.6326 29.577C15.8909 29.577 14.2524 29.2533 12.7171 28.6059C11.1818 27.9583 9.84618 27.0797 8.71035 25.9699C7.57452 24.8599 6.67711 23.5562 6.01813 22.0588C5.35891 20.5614 5.0293 18.9615 5.0293 17.259C5.0293 15.5544 5.36001 13.9507 6.02144 12.4481C6.68311 10.9454 7.58088 9.63823 8.71476 8.52656C9.84888 7.4149 11.1809 6.53658 12.7109 5.89162C14.2408 5.24643 15.8755 4.92383 17.615 4.92383C19.3567 4.92383 20.9952 5.24751 22.5306 5.89486C24.0659 6.54245 25.4015 7.42112 26.5373 8.53087C27.6731 9.64086 28.5705 10.9446 29.2295 12.442C29.8887 13.9393 30.2184 15.5393 30.2184 17.2418C30.2184 18.9464 29.8876 20.5501 29.2262 22.0527C28.5645 23.5554 27.6668 24.8626 26.5329 25.9742C25.3988 27.0859 24.0667 27.9642 22.5368 28.6092C21.0068 29.2544 19.3721 29.577 17.6326 29.577ZM17.6227 28.6605C20.867 28.6605 23.6212 27.5528 25.8855 25.3374C28.1499 23.1222 29.282 20.4269 29.282 17.2515C29.282 14.0763 28.1502 11.3806 25.8866 9.16445C23.6233 6.94831 20.8694 5.84023 17.6249 5.84023C14.3807 5.84023 11.6264 6.94795 9.36211 9.16337C7.09779 11.3786 5.96562 14.0739 5.96562 17.2493C5.96562 20.4245 7.09742 23.1202 9.36101 25.3363C11.6244 27.5525 14.3783 28.6605 17.6227 28.6605ZM17.6238 23.8988C18.755 23.8988 19.7995 23.6475 20.7574 23.1449C21.715 22.642 22.5105 21.959 23.1438 21.0961C23.2703 20.8802 23.2794 20.6615 23.1709 20.4398C23.0627 20.2182 22.8757 20.1074 22.6099 20.1074H12.6521C12.3804 20.1074 12.1895 20.2182 12.0793 20.4398C11.9691 20.6615 11.9835 20.8765 12.1223 21.0849C12.7423 21.9546 13.5364 22.641 14.5046 23.1441C15.4727 23.6473 16.5125 23.8988 17.6238 23.8988Z" fill="currentColor"/> <path d="M22.3488 15.0762C22.7483 15.0762 23.0857 14.9392 23.3611 14.6654C23.6365 14.3918 23.7742 14.0594 23.7742 13.6681C23.7742 13.2771 23.6343 12.9469 23.3545 12.6773C23.075 12.4078 22.7353 12.273 22.3356 12.273C21.9361 12.273 21.5986 12.41 21.3232 12.6838C21.0479 12.9574 20.9102 13.2898 20.9102 13.6811C20.9102 14.0721 21.0501 14.4023 21.3299 14.6719C21.6094 14.9414 21.9491 15.0762 22.3488 15.0762ZM12.9121 15.0762C13.3116 15.0762 13.649 14.9392 13.9244 14.6654C14.1998 14.3918 14.3375 14.0594 14.3375 13.6681C14.3375 13.2771 14.1976 12.9469 13.9178 12.6773C13.6383 12.4078 13.2986 12.273 12.8989 12.273C12.4994 12.273 12.1619 12.41 11.8865 12.6838C11.6111 12.9574 11.4734 13.2898 11.4734 13.6811C11.4734 14.0721 11.6133 14.4023 11.8931 14.6719C12.1727 14.9414 12.5123 15.0762 12.9121 15.0762ZM17.6326 29.577C15.8909 29.577 14.2524 29.2533 12.7171 28.6059C11.1818 27.9583 9.84618 27.0797 8.71035 25.9699C7.57452 24.8599 6.67711 23.5562 6.01813 22.0588C5.35891 20.5614 5.0293 18.9615 5.0293 17.259C5.0293 15.5544 5.36001 13.9507 6.02144 12.4481C6.68311 10.9454 7.58088 9.63823 8.71476 8.52656C9.84888 7.4149 11.1809 6.53658 12.7109 5.89162C14.2408 5.24643 15.8755 4.92383 17.615 4.92383C19.3567 4.92383 20.9952 5.24751 22.5306 5.89486C24.0659 6.54245 25.4015 7.42112 26.5373 8.53087C27.6731 9.64086 28.5705 10.9446 29.2295 12.442C29.8887 13.9393 30.2184 15.5393 30.2184 17.2418C30.2184 18.9464 29.8876 20.5501 29.2262 22.0527C28.5645 23.5554 27.6668 24.8626 26.5329 25.9742C25.3988 27.0859 24.0667 27.9642 22.5368 28.6092C21.0068 29.2544 19.3721 29.577 17.6326 29.577ZM17.6227 28.6605C20.867 28.6605 23.6212 27.5528 25.8855 25.3374C28.1499 23.1222 29.282 20.4269 29.282 17.2515C29.282 14.0763 28.1502 11.3806 25.8866 9.16445C23.6233 6.94831 20.8694 5.84023 17.6249 5.84023C14.3807 5.84023 11.6264 6.94795 9.36211 9.16337C7.09779 11.3786 5.96562 14.0739 5.96562 17.2493C5.96562 20.4245 7.09742 23.1202 9.36101 25.3363C11.6244 27.5525 14.3783 28.6605 17.6227 28.6605ZM17.6238 23.8988C18.755 23.8988 19.7995 23.6475 20.7574 23.1449C21.715 22.642 22.5105 21.959 23.1438 21.0961C23.2703 20.8802 23.2794 20.6615 23.1709 20.4398C23.0627 20.2182 22.8757 20.1074 22.6099 20.1074H12.6521C12.3804 20.1074 12.1895 20.2182 12.0793 20.4398C11.9691 20.6615 11.9835 20.8765 12.1223 21.0849C12.7423 21.9546 13.5364 22.641 14.5046 23.1441C15.4727 23.6473 16.5125 23.8988 17.6238 23.8988Z" fill="currentColor"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.4375 9.84375L15.4688 9.84375" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.4375 12.6562L15.4688 12.6563" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.8125 17.5781C3.18546 17.5781 3.54315 17.43 3.80687 17.1662C4.07059 16.9025 4.21875 16.5448 4.21875 16.1719L4.21875 5.625C4.21875 5.43852 4.29283 5.25968 4.42469 5.12782C4.55655 4.99595 4.7354 4.92188 4.92188 4.92188L18.9844 4.92188C19.1709 4.92188 19.3497 4.99595 19.4816 5.12782C19.6134 5.25968 19.6875 5.43852 19.6875 5.625L19.6875 16.1719C19.6875 16.5448 19.5393 16.9025 19.2756 17.1662C19.0119 17.43 18.6542 17.5781 18.2813 17.5781L2.8125 17.5781Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2.8125 17.5781C2.43954 17.5781 2.08185 17.43 1.81813 17.1662C1.55441 16.9025 1.40625 16.5448 1.40625 16.1719L1.40625 7.73438" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1279_3967)"> <g clip-path="url(#clip0_1279_3967)">
<path d="M12.2344 10.875H6.79688" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/> <path d="M12.2344 10.875H6.79688" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.11956 18.8005C4.07177 18.931 4.06515 19.073 4.10058 19.2074C4.13601 19.3418 4.2118 19.4621 4.31772 19.5521C4.42364 19.6421 4.55461 19.6975 4.69297 19.7108C4.83133 19.724 4.97043 19.6945 5.09152 19.6263L19.365 11.4624C19.4712 11.4036 19.5597 11.3175 19.6214 11.2129C19.6831 11.1083 19.7156 10.9891 19.7156 10.8677C19.7156 10.7463 19.6831 10.6271 19.6214 10.5225C19.5597 10.4179 19.4712 10.3317 19.365 10.273L5.09152 2.12945C4.97079 2.06193 4.83234 2.03285 4.69466 2.04609C4.55697 2.05934 4.4266 2.11428 4.32096 2.20357C4.21533 2.29286 4.13945 2.41226 4.10346 2.54582C4.06747 2.67938 4.07309 2.82074 4.11956 2.95102L6.79668 10.8753L4.11956 18.8005Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/> <path d="M4.11956 18.8005C4.07177 18.931 4.06515 19.073 4.10058 19.2074C4.13601 19.3418 4.2118 19.4621 4.31772 19.5521C4.42364 19.6421 4.55461 19.6975 4.69297 19.7108C4.83133 19.724 4.97043 19.6945 5.09152 19.6263L19.365 11.4624C19.4712 11.4036 19.5597 11.3175 19.6214 11.2129C19.6831 11.1083 19.7156 10.9891 19.7156 10.8677C19.7156 10.7463 19.6831 10.6271 19.6214 10.5225C19.5597 10.4179 19.4712 10.3317 19.365 10.273L5.09152 2.12945C4.97079 2.06193 4.83234 2.03285 4.69466 2.04609C4.55697 2.05934 4.4266 2.11428 4.32096 2.20357C4.21533 2.29286 4.13945 2.41226 4.10346 2.54582C4.06747 2.67938 4.07309 2.82074 4.11956 2.95102L6.79668 10.8753L4.11956 18.8005Z" stroke="currentColor" stroke-width="1.125" stroke-linecap="round" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" xmlns="http://www.w3.org/2000/svg">
<path d="M7 3.75v12.5"/>
<path d="M13 3.75v12.5"/>
</svg>

After

Width:  |  Height:  |  Size: 199 B

+10
View File
@@ -0,0 +1,10 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_21269)">
<path d="M5.625 3.1154V16.8841C5.62704 16.994 5.65802 17.1014 5.7148 17.1955C5.77158 17.2896 5.85217 17.367 5.94843 17.42C6.0447 17.473 6.15323 17.4997 6.2631 17.4974C6.37297 17.4951 6.48028 17.4638 6.57422 17.4068L17.8305 10.5224C17.9204 10.468 17.9947 10.3913 18.0463 10.2998C18.0979 10.2082 18.1251 10.1049 18.1251 9.99977C18.1251 9.89467 18.0979 9.79135 18.0463 9.69979C17.9947 9.60823 17.9204 9.53154 17.8305 9.47711L6.57422 2.59274C6.48028 2.53571 6.37297 2.50447 6.2631 2.50214C6.15323 2.49982 6.0447 2.52651 5.94843 2.57951C5.85217 2.63252 5.77158 2.70996 5.7148 2.80405C5.65802 2.89813 5.62704 3.00552 5.625 3.1154Z" stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_21269">
<rect width="19.5" height="19.5" fill="white" transform="scale(1.02564)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 993 B

+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3v9"/>
<path d="M15.5 4.5a8 8 0 1 1-7 0"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 109 109" fill="none">
<g clip-path="url(#clip0_1_29013)">
<path d="M84.9609 74.7656V27.1875C84.9609 24.4835 83.8868 21.8903 81.9748 19.9783C80.0628 18.0663 77.4696 16.9922 74.7656 16.9922H16.9922" stroke="#C3C3C3" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.1797 44.1797H71.3672" stroke="#F55D6D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.1797 57.7734H71.3672" stroke="#F55D6D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.1953 33.9844C10.1953 33.9844 6.79688 31.4355 6.79688 27.1875C6.79688 24.4835 7.87102 21.8903 9.78301 19.9783C11.695 18.0663 14.2882 16.9922 16.9922 16.9922C19.6962 16.9922 22.2894 18.0663 24.2014 19.9783C26.1134 21.8903 27.1875 24.4835 27.1875 27.1875V81.5625C27.1875 84.2665 28.2616 86.8597 30.1736 88.7717C32.0856 90.6837 34.6788 91.7578 37.3828 91.7578M37.3828 91.7578C40.0868 91.7578 42.68 90.6837 44.592 88.7717C46.504 86.8597 47.5781 84.2665 47.5781 81.5625C47.5781 77.3145 44.1797 74.7656 44.1797 74.7656H91.7578C91.7578 74.7656 95.1562 77.3145 95.1562 81.5625C95.1562 84.2665 94.0821 86.8597 92.1701 88.7717C90.2581 90.6837 87.6649 91.7578 84.9609 91.7578H37.3828Z" stroke="#C3C3C3" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_29013">
<rect width="108.75" height="108.75" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_21319)">
<path d="M11.8125 2.625H2.1875C1.94588 2.625 1.75 2.82088 1.75 3.0625V10.9375C1.75 11.1791 1.94588 11.375 2.1875 11.375H11.8125C12.0541 11.375 12.25 11.1791 12.25 10.9375V3.0625C12.25 2.82088 12.0541 2.625 11.8125 2.625Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.1875 4.8125C9.1875 5.39266 8.95703 5.94906 8.5468 6.3593C8.13656 6.76953 7.58016 7 7 7C6.41984 7 5.86344 6.76953 5.4532 6.3593C5.04297 5.94906 4.8125 5.39266 4.8125 4.8125" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_21319">
<rect width="13.5" height="13.5" fill="white" transform="scale(1.03704)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 855 B

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 171 171" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M85.125 148.969C120.385 148.969 148.969 120.385 148.969 85.125C148.969 49.8651 120.385 21.2812 85.125 21.2812C49.8651 21.2812 21.2812 49.8651 21.2812 85.125C21.2812 120.385 49.8651 148.969 85.125 148.969Z" stroke="#CACACA" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M61.1816 65.2207C64.8287 65.2207 67.7852 68.1772 67.7852 71.8242C67.7852 75.4712 64.8287 78.4277 61.1816 78.4277C57.5346 78.4277 54.5781 75.4712 54.5781 71.8242C54.5781 68.1772 57.5346 65.2207 61.1816 65.2207Z" fill="#F55D6D" stroke="#F55D6D" stroke-width="0.09375"/>
<path d="M109.064 65.2207C112.711 65.2207 115.668 68.1772 115.668 71.8242C115.668 75.4712 112.711 78.4277 109.064 78.4277C105.417 78.4277 102.461 75.4712 102.461 71.8242C102.461 68.1772 105.417 65.2207 109.064 65.2207Z" fill="#F55D6D" stroke="#F55D6D" stroke-width="0.09375"/>
<path d="M111.727 117.047C106.207 107.504 96.9427 101.086 85.125 101.086C73.3073 101.086 64.0433 107.504 58.5234 117.047" stroke="#F55D6D" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<path d="M10 12.5C11.7259 12.5 13.125 11.1009 13.125 9.375C13.125 7.64911 11.7259 6.25 10 6.25C8.27411 6.25 6.875 7.64911 6.875 9.375C6.875 11.1009 8.27411 12.5 10 12.5Z"/>
<path d="M4.98438 15.5757C5.45462 14.6494 6.17216 13.8715 7.05745 13.3281C7.94275 12.7847 8.96123 12.4971 10 12.4971C11.0388 12.4971 12.0572 12.7847 12.9425 13.3281C13.8278 13.8715 14.5454 14.6494 15.0156 15.5757"/>
<path d="M17.3963 8.75003C17.6599 10.3167 17.4189 11.9266 16.7079 13.3474C15.9969 14.7681 14.8528 15.9261 13.4408 16.6542C12.0287 17.3823 10.4218 17.6428 8.85205 17.3981C7.28231 17.1533 5.83096 16.416 4.70757 15.2926C3.58419 14.1693 2.84689 12.7179 2.60215 11.1482C2.35742 9.57842 2.6179 7.97151 3.34598 6.55946C4.07407 5.14741 5.23209 4.00329 6.65283 3.2923C8.07357 2.58132 9.6835 2.34026 11.2502 2.60394"/>
<path d="M14.375 4.375L15.625 5.625L18.125 3.125"/>
</svg>

After

Width:  |  Height:  |  Size: 1007 B

+6
View File
@@ -0,0 +1,6 @@
<svg viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
<path d="M5.48438 8.53125C7.16712 8.53125 8.53125 7.16712 8.53125 5.48438C8.53125 3.80163 7.16712 2.4375 5.48438 2.4375C3.80163 2.4375 2.4375 3.80163 2.4375 5.48438C2.4375 7.16712 3.80163 8.53125 5.48438 8.53125Z" stroke-width="0.75"/>
<path d="M1.21875 10.5625C2.2623 9.3209 3.73547 8.53125 5.48438 8.53125C7.23328 8.53125 8.70645 9.3209 9.75 10.5625" stroke-width="0.75"/>
<path d="M10.1562 3.49096C10.4243 4.12144 10.5625 4.79952 10.5625 5.48463C10.5625 6.16975 10.4243 6.84782 10.1562 7.47831" stroke-width="0.75"/>
<path d="M11.6468 2.84375C12.0033 3.67846 12.1871 4.57672 12.1871 5.48438C12.1871 6.39203 12.0033 7.29029 11.6468 8.125" stroke-width="0.75"/>
</svg>

After

Width:  |  Height:  |  Size: 820 B

+12
View File
@@ -0,0 +1,12 @@
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_21228)">
<path d="M8.125 5.6875L5.6875 4.0625V7.3125L8.125 5.6875Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.9688 2.4375H2.03125C1.80688 2.4375 1.625 2.61938 1.625 2.84375V8.53125C1.625 8.75562 1.80688 8.9375 2.03125 8.9375H10.9688C11.1931 8.9375 11.375 8.75562 11.375 8.53125V2.84375C11.375 2.61938 11.1931 2.4375 10.9688 2.4375Z" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.625 10.5625H11.375" stroke="#B8B8B8" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1_21228">
<rect width="12.75" height="12.75" fill="white" transform="scale(1.01961)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 863 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

+4
View File
@@ -4,6 +4,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
font-size: 106.25%;
}
body { body {
margin: 0 !important; margin: 0 !important;
padding: 0 !important; padding: 0 !important;
+21 -21
View File
@@ -1,6 +1,6 @@
@font-face { @font-face {
font-family: montserrat; font-family: montserrat;
src: url('../fonts/montserrat/Montserrat-ExtraLight.ttf') format('truetype'); src: url('../fonts/montserrat/Montserrat-Regular.ttf') format('truetype');
font-weight: 200; font-weight: 200;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -16,8 +16,8 @@
@font-face { @font-face {
font-family: aria; font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-Light.woff2') format('woff2'); src: url('../fonts/aria/AriaWeb-Normal.woff2') format('woff2');
font-weight: 300; font-weight: 200;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@@ -25,7 +25,7 @@
@font-face { @font-face {
font-family: aria; font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-Regular.woff2') format('woff2'); src: url('../fonts/aria/AriaWebFaNum-Regular.woff2') format('woff2');
font-weight: 400; font-weight: 300;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@@ -33,6 +33,14 @@
@font-face { @font-face {
font-family: aria; font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-Medium.woff2') format('woff2'); src: url('../fonts/aria/AriaWebFaNum-Medium.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: aria;
src: url('../fonts/aria/AriaWebFaNum-SemiBold.woff2') format('woff2');
font-weight: 500; font-weight: 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -46,25 +54,17 @@
font-display: swap; font-display: swap;
} }
@font-face {
font-family: aria;
src: url('../fonts/aria/AriaWeb-Normal.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanwebregularfanum.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face { @font-face {
font-family: iran-yekan; font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanwebmediumfanum.woff') format('woff'); src: url('../fonts/iran-yekan/iranyekanwebmediumfanum.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanwebboldfanum.woff') format('woff');
font-weight: 500; font-weight: 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -80,7 +80,7 @@
@font-face { @font-face {
font-family: iran-yekan; font-family: iran-yekan;
src: url('../fonts/iran-yekan/iranyekanweblightfanum.woff') format('woff'); src: url('../fonts/iran-yekan/iranyekanwebregularfanum.woff') format('woff');
font-weight: 300; font-weight: 300;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
+147
View File
@@ -0,0 +1,147 @@
<template>
<span
class="badge"
:class="[`badge--${variant}`, `badge--${size}`, { 'badge--clickable': clickable }]"
:role="clickable ? 'button' : undefined"
@click="onClick"
>
<span v-if="dot" class="badge__dot" />
<SvgIcon v-if="icon" :name="icon" :size="iconSize" class="badge__icon" :color="iconColor" />
<slot name="prepend" />
<span v-if="label" class="badge__label">{{ label }}</span>
<span v-if="$slots.default || value" class="badge__value">
<slot>{{ value }}</slot>
</span>
<slot name="append" />
</span>
</template>
<script setup>
import { computed } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
/** @type {import('vue').PropType<'neutral' | 'success' | 'danger' | 'warning' | 'primary' | 'info' | 'cyan' | 'blue'>} */
variant: { type: String, default: 'neutral' },
/** @type {import('vue').PropType<'sm' | 'md' | 'lg'>} */
size: { type: String, default: 'md' },
label: { type: String, default: '' },
value: { type: [String, Number], default: '' },
/** @type {import('vue').PropType<import('@/components/icons/icon-names').IconName | ''>} */
icon: { type: String, default: '' },
dot: { type: Boolean, default: false },
clickable: { type: Boolean, default: false },
})
const emit = defineEmits(['click'])
const ICON_SIZE_MAP = { sm: 11, md: 14, lg: 16 }
const iconSize = computed(() => ICON_SIZE_MAP[props.size] ?? 14)
const iconColor = computed(() => 'currentColor')
const onClick = (event) => {
if (!props.clickable) return
emit('click', event)
}
</script>
<style lang="scss" scoped>
.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
border-radius: 0.75rem;
font-family: var(--font-family-fa);
white-space: nowrap;
line-height: 1.5;
transition: filter 0.15s ease;
&--clickable {
cursor: pointer;
&:hover {
filter: brightness(0.96);
}
}
&__dot {
width: 0.4em;
height: 0.4em;
border-radius: 9999px;
background: currentcolor;
flex-shrink: 0;
}
&__icon {
flex-shrink: 0;
}
&__label {
font-weight: 300;
color: inherit;
opacity: 0.85;
}
&__value {
font-weight: 500;
}
&--sm {
padding: 0.2rem 0.625rem;
font-size: 0.65rem;
gap: 0.3rem;
border-radius: 0.625rem;
}
&--md {
padding: 0.3rem 0.875rem;
font-size: 0.75rem;
}
&--lg {
padding: 0.4rem 1rem;
font-size: 0.875rem;
border-radius: 0.875rem;
}
&--neutral {
background: rgba(107, 107, 107, 5%);
color: #535353;
}
&--success {
background: rgba(0, 153, 76, 10%);
color: #00994c;
}
&--danger {
background: rgba(243, 102, 117, 8%);
color: #cc2831;
}
&--warning {
background: rgba(204, 154, 40, 10%);
color: #cc6f00;
}
&--primary {
background: rgba(0, 112, 116, 8%);
color: #007074;
}
&--info {
background: rgba(104, 104, 104, 10%);
color: #686868;
}
&--cyan {
background: rgba(104, 104, 104, 10%);
color: #686868;
}
&--blue {
background: rgba(0, 72, 255, 8%);
color: #0048ff;
}
}
</style>
+1
View File
@@ -79,6 +79,7 @@ const handleClick = (event) => {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
text-decoration: none; text-decoration: none;
padding: 0 1rem; padding: 0 1rem;
white-space: nowrap;
&__text { &__text {
line-height: 1.25rem; line-height: 1.25rem;
+3 -4
View File
@@ -28,11 +28,10 @@
</template> </template>
<script setup> <script setup>
import { computed, getCurrentInstance, onBeforeUnmount, useSlots, watch } from 'vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { useModalStore } from '@/store/modal' import { useModalStore } from '@/store/modal'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import { computed, getCurrentInstance, onBeforeUnmount, useSlots, watch } from 'vue'
const props = defineProps({ const props = defineProps({
name: { type: String, default: '' }, name: { type: String, default: '' },
+3 -6
View File
@@ -28,10 +28,7 @@
role="tooltip" role="tooltip"
> >
{{ tooltip }} {{ tooltip }}
<span <span class="circle-button__tooltip-arrow" :style="arrowStyle" />
class="circle-button__tooltip-arrow"
:style="arrowStyle"
/>
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
@@ -98,8 +95,8 @@ const updatePosition = async () => {
const side = result.placement.split('-')[0] const side = result.placement.split('-')[0]
const oppositeSide = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[side] const oppositeSide = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[side]
arrowStyle.value = { arrowStyle.value = {
left: arrowData.x != null ? `${arrowData.x}px` : '', left: arrowData.x == null ? '' : `${arrowData.x}px`,
top: arrowData.y != null ? `${arrowData.y}px` : '', top: arrowData.y == null ? '' : `${arrowData.y}px`,
[oppositeSide]: '-0.25rem', [oppositeSide]: '-0.25rem',
} }
} }
+7 -6
View File
@@ -23,7 +23,11 @@
:loading="!!data?.confirmLoading" :loading="!!data?.confirmLoading"
custom-class="confirm-modal__btn confirm-modal__btn--confirm" custom-class="confirm-modal__btn confirm-modal__btn--confirm"
@click="confirm(data)" @click="confirm(data)"
/> >
<template #appendIcon>
<SvgIcon name="arrow-left" :size="18" color="#fff" />
</template>
</BaseButton>
</div> </div>
</div> </div>
</template> </template>
@@ -31,9 +35,10 @@
</template> </template>
<script setup> <script setup>
import SvgIcon from './icons/SvgIcon.vue'
import { useModalStore } from '@/store/modal'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import { useModalStore } from '@/store/modal'
defineOptions({ name: 'ConfirmModal' }) defineOptions({ name: 'ConfirmModal' })
@@ -82,10 +87,6 @@ const cancel = (data) => {
&__btn { &__btn {
min-width: 100px; min-width: 100px;
padding: 0.5rem 1.5rem; padding: 0.5rem 1.5rem;
&--confirm {
padding-inline: 1.5rem 2.5rem;
}
} }
} }
</style> </style>
+2 -1
View File
@@ -39,8 +39,9 @@ const social = [
.copyright { .copyright {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
justify-content: center; justify-content: flex-end;
align-items: center; align-items: center;
margin-top: 3rem;
&__text { &__text {
padding: 0 0.25rem; padding: 0 0.25rem;
+36 -5
View File
@@ -1,7 +1,13 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="dropdown"> <Transition name="dropdown">
<div v-if="modelValue" ref="floatingEl" class="dropdown-menu" :style="floatingStyle"> <div
v-if="modelValue"
ref="floatingEl"
class="dropdown-menu"
:class="{ 'dropdown-menu--center': align === 'center' }"
:style="floatingStyle"
>
<ul class="dropdown-menu__list"> <ul class="dropdown-menu__list">
<li <li
v-for="(item, i) in items" v-for="(item, i) in items"
@@ -11,11 +17,21 @@
<button <button
type="button" type="button"
class="dropdown-menu__item" class="dropdown-menu__item"
:class="{ 'dropdown-menu__item--danger': item.danger }" :class="{
'dropdown-menu__item--danger': item.danger,
'dropdown-menu__item--colored': item.color,
}"
:style="{ color: item.color }"
:disabled="item.disabled" :disabled="item.disabled"
@click="handleClick(item)" @click="handleClick(item)"
> >
<SvgIcon v-if="item.icon" :name="item.icon" :size="16" class="dropdown-menu__icon" /> <SvgIcon
v-if="item.icon"
:name="item.icon"
:size="16"
:color="item.iconColor ?? item.color ?? 'currentColor'"
class="dropdown-menu__icon"
/>
<span class="dropdown-menu__label">{{ item.label }}</span> <span class="dropdown-menu__label">{{ item.label }}</span>
</button> </button>
</li> </li>
@@ -27,6 +43,7 @@
<script setup> <script setup>
import { onBeforeUnmount, ref, watch } from 'vue' import { onBeforeUnmount, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { import {
autoUpdate, autoUpdate,
computePosition, computePosition,
@@ -35,8 +52,6 @@ import {
shift, shift,
} from '@floating-ui/dom' } from '@floating-ui/dom'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
reference: { type: [Object, null], default: null }, reference: { type: [Object, null], default: null },
@@ -44,6 +59,7 @@ const props = defineProps({
placement: { type: String, default: 'bottom-end' }, placement: { type: String, default: 'bottom-end' },
offset: { type: Number, default: 8 }, offset: { type: Number, default: 8 },
minWidth: { type: String, default: '12rem' }, minWidth: { type: String, default: '12rem' },
align: { type: String, default: 'start' },
}) })
const emit = defineEmits(['update:modelValue', 'select']) const emit = defineEmits(['update:modelValue', 'select'])
@@ -181,6 +197,21 @@ onBeforeUnmount(unmountFloating)
&__icon { &__icon {
flex-shrink: 0; flex-shrink: 0;
} }
&__item--colored &__icon {
:deep([stroke]) {
stroke: currentcolor;
}
}
&--center &__item {
justify-content: center;
}
&--center &__label {
flex: 0 1 auto;
text-align: center;
}
} }
.dropdown-enter-active, .dropdown-enter-active,
+91 -13
View File
@@ -11,14 +11,17 @@
</div> </div>
<div class="header-block__title"> <div class="header-block__title">
<slot> <slot>
<div v-if="!hasSlotContent && pageTitle">{{ pageTitle }}</div> <div v-if="pageTitle || pageSubtitle" class="header-block__heading">
<span v-if="pageSubtitle" class="header-block__heading-eyebrow">{{ pageTitle }}</span>
<span>{{ pageSubtitle || pageTitle }}</span>
</div>
</slot> </slot>
</div> </div>
<div class="header-block__icons"> <div class="header-block__icons">
<ul> <ul>
<li v-for="item in iconList" :key="item.icon"> <li v-for="item in iconList" :key="item.icon">
<button type="button" class="header-block__icon-btn" @click="item.action"> <button type="button" class="header-block__icon-btn" @click="item.action">
<SvgIcon :name="item.icon" :size="24" color="#9a9a9a" /> <SvgIcon :name="item.icon" :size="24" color="var(--header-icon-color)" />
<span v-if="item.count" class="header-block__icon-badge">{{ item.count }}</span> <span v-if="item.count" class="header-block__icon-badge">{{ item.count }}</span>
</button> </button>
</li> </li>
@@ -27,34 +30,91 @@
</div> </div>
<div class="header-block__title-mobile"> <div class="header-block__title-mobile">
<slot> <slot>
<div v-if="!hasSlotContent && pageTitle">{{ pageTitle }}</div> <div v-if="pageTitle || pageSubtitle" class="header-block__heading">
<span v-if="pageSubtitle" class="header-block__heading-eyebrow">{{ pageTitle }}</span>
<span>{{ pageSubtitle || pageTitle }}</span>
</div>
</slot> </slot>
</div> </div>
<DropdownMenu
v-model="userMenuOpen"
:reference="userMenuTrigger"
:items="userMenuItems"
placement="bottom-end"
align="center"
min-width="9rem"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, useSlots } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { gallery } from '@/utils/gallery'
import { useUIStore } from '@/store/ui' import { useUIStore } from '@/store/ui'
import { gallery } from '@/utils/gallery'
import useAuth from '@/composables/useAuth'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import DropdownMenu from '@/components/DropdownMenu.vue'
import { useLogoutMutation } from '@/services/query/auth'
defineProps({ defineProps({
pageTitle: { type: String, default: '' }, pageTitle: { type: String, default: '' },
pageSubtitle: { type: String, default: '' },
}) })
const slots = useSlots()
const hasSlotContent = computed(() => !!slots.default)
const ui = useUIStore() const ui = useUIStore()
const router = useRouter()
const { role, clearSession } = useAuth()
const logoutMutation = useLogoutMutation()
const logo = gallery.authLogo const logo = gallery.authLogo
const userMenuOpen = ref(false)
const userMenuTrigger = ref(null)
const openUserMenu = (event) => {
const trigger = event.currentTarget
if (userMenuOpen.value && userMenuTrigger.value === trigger) {
userMenuOpen.value = false
return
}
userMenuTrigger.value = trigger
userMenuOpen.value = true
}
const goEditProfile = () => {
router.push({ name: 'student-profile' }).catch(() => {})
}
const onLogout = async () => {
try {
await logoutMutation.mutateAsync()
} catch {
/* ignore */
} finally {
clearSession()
router.push({ name: 'login' })
}
}
const userMenuItems = computed(() => [
...(role.value === 'student'
? [{ key: 'edit-profile', label: 'ویرایش پروفایل', icon: 'pencil', onClick: goEditProfile }]
: []),
{
key: 'logout',
label: 'خروج',
icon: 'heart',
color: 'var(--color-primary)',
onClick: onLogout,
},
])
const iconList = [ const iconList = [
{ icon: 'chat', action: () => {} }, // { icon: 'chat', action: () => {} },
{ icon: 'bell', action: () => {}, count: 5 }, // { icon: 'bell', action: () => {}, count: 5 },
{ icon: 'user', action: () => {} }, { icon: 'user', action: openUserMenu },
] ]
</script> </script>
@@ -122,6 +182,18 @@ const iconList = [
} }
} }
&__heading {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
&__heading-eyebrow {
font-size: 0.75rem;
font-weight: 400;
color: #a7a7a7;
}
&__icons { &__icons {
flex-basis: 33.33%; flex-basis: 33.33%;
@@ -137,12 +209,18 @@ const iconList = [
} }
&__icon-btn { &__icon-btn {
--header-icon-color: #9a9a9a;
position: relative; position: relative;
border: none; border: none;
background: transparent; background: transparent;
border-radius: 9999px; border-radius: 9999px;
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
&:hover {
--header-icon-color: var(--color-primary);
}
} }
&__icon-badge { &__icon-badge {
+1 -1
View File
@@ -25,8 +25,8 @@ defineProps({
&__bar { &__bar {
background-color: var(--color-primary); background-color: var(--color-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 5%);
border-radius: 1rem; border-radius: 1rem;
box-shadow: 0 3px 8px rgba(243, 102, 117, 45%);
height: 1.9rem; height: 1.9rem;
width: 0.375rem; width: 0.375rem;
flex-shrink: 0; flex-shrink: 0;
+116
View File
@@ -0,0 +1,116 @@
<template>
<div class="my-tinymce">
<Editor v-model="contentValue" :init="myInit" />
</div>
</template>
<script setup>
import axios from 'axios'
import tinymce from 'tinymce/tinymce'
import '@/plugins/tinymce/importTinymce'
import Editor from '@tinymce/tinymce-vue'
import { initTiny } from '@/plugins/tinymce/tinymce'
import { onMounted, toRefs, ref, reactive, defineProps, defineEmits, watch } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
// placeholder
placeholder: {
type: String,
default: 'Please enter content',
},
// Default style
style: {
type: Object,
default: () => {
return { width: '100%', heigth: '400' }
},
},
// image upload server address
imgUploadUrl: {
type: String,
default: '/api/v1/media',
},
batchId: {
type: String,
},
})
const emit = defineEmits(['update:modelValue'])
// Parameter custom initialization
const customer = (init) => {
// Allow the outside world to pass in height and placeholder
init.height = props.style.heigth
init.placeholder = props.placeholder
// Paste pictures and automatically process base64
init.urlconverter_callback = (url, node, _onSave, _name) => {
if (node === 'img' && url.startsWith('blob:')) {
tinymce.activeEditor && tinymce.activeEditor.uploadImages()
}
return url
}
// upload picture
init.images_upload_handler = (blobInfo, success, failure) => {
imgUploadFn(blobInfo, success, failure)
}
return init
}
// const myInit = ref(customer(initTiny(props.batchId)));
const state = reactive({
myInit: ref(customer(initTiny(props.batchId))),
contentValue: props.modelValue, // binding text
timeout: null,
})
onMounted(() => {
tinymce.init({})
})
// Listen for text changes and pass them to the outside world
watch(
() => state.contentValue,
() => {
debounce(() => {
emit('update:modelValue', state.contentValue)
})
}
)
// Listen to the default value. The first time a v-model is passed in from the outside world, it is assigned to contentValue.
watch(
() => props.modelValue,
(n) => {
if (n && n !== state.contentValue) {
state.contentValue = n
}
}
)
const debounce = (fn, wait = 400) => {
if (state.timeout !== null) {
clearTimeout(state.timeout)
}
state.timeout = setTimeout(fn, wait)
}
const imgUploadFn = async (blobInfo, success, failure) => {
// Can limit image size
// if (blobInfo.blob().size / 1024 / 1024 > 2) {
// failure('Upload failed, please control the image size within 2M')
// } else {}
const formData = new FormData()
formData.append('file', blobInfo.blob(), blobInfo.filename())
formData.append('batch_id', props.batchId)
const response = await axios.post('https://api.madomotor.ir'.props.imgUploadUrl, formData)
if (response && response.status == 200) {
return success(response.data.data.url)
}
return failure(`HTTP Error: ${response.status}`)
}
const { myInit, contentValue } = toRefs(state)
</script>
@@ -0,0 +1,100 @@
<template>
<div class="message-attachments">
<template v-for="item in media" :key="item.id">
<a
v-if="isImage(item)"
:href="item.url"
target="_blank"
rel="noopener"
class="message-attachments__image-link"
>
<img :src="item.url" :alt="item.fileName" class="message-attachments__image" />
</a>
<a
v-else
:href="item.downloadUrl || item.url"
target="_blank"
rel="noopener"
class="message-attachments__file"
>
<SvgIcon name="file" :size="18" color="currentColor" />
<span class="message-attachments__name">{{ item.fileName }}</span>
<span class="message-attachments__size">{{ formatFileSize(item.fileSize) }}</span>
</a>
</template>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({
media: { type: Array, default: () => [] },
})
const isImage = (item) =>
(item.mimeType || '').startsWith('image/') || /\.(jpe?g|png|gif|webp)$/i.test(item.fileName || '')
const formatFileSize = (bytes) => {
if (!bytes) return ''
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
</script>
<style lang="scss" scoped>
.message-attachments {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-bottom: 0.375rem;
&__image-link {
display: block;
max-width: 16rem;
}
&__image {
display: block;
width: 100%;
max-height: 12rem;
object-fit: cover;
border-radius: 0.625rem;
}
&__file {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 8%);
color: inherit;
text-decoration: none;
max-width: 100%;
&:hover {
background: rgba(0, 0, 0, 14%);
}
}
&__name {
font-family: var(--font-family-fa);
font-size: 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
&__size {
font-family: var(--font-family-en);
font-size: 0.65rem;
opacity: 0.75;
white-space: nowrap;
}
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="no-items"> <div class="no-items">
<div class="no-items__inner"> <div class="no-items__inner">
<SvgIcon name="warning" :size="120" color="#cbd5e1" /> <SvgIcon name="smiley-sad" :size="171" />
<p class="no-items__title">{{ title }}</p> <p class="no-items__title">{{ title }}</p>
<p class="no-items__desc">{{ desc }}</p> <p class="no-items__desc">{{ desc }}</p>
</div> </div>
+113 -26
View File
@@ -1,23 +1,61 @@
<template> <template>
<div class="tabs-block"> <div class="tabs-block">
<div class="tabs-block__nav"> <div class="tabs-block__nav">
<div class="tabs-block__tabs">
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.name" :key="tab.name"
type="button" type="button"
class="tabs-block__btn" class="tabs-block__tab"
:class="{ 'tabs-block__btn--active': activeTab === tab.name }" :class="{ 'tabs-block__tab--active': activeTab === tab.name }"
@click="onChange(tab.name)" @click="onChange(tab.name)"
> >
<SvgIcon <span class="tabs-block__tab-card">
v-if="tab.icon" <SvgIcon v-if="tab.icon" :name="tab.icon" :size="18" color="#bcbcbc" />
:name="tab.icon" <span class="tabs-block__tab-label">{{ tab.label }}</span>
:size="16" </span>
:color="activeTab === tab.name ? 'var(--color-primary)' : '#bcbcbc'"
/>
<span>{{ tab.label }}</span>
</button> </button>
</div> </div>
<template v-for="tab in tabs">
<div
v-if="tab.hasButton"
:key="`btn-${tab.name}`"
class="tabs-block__action tabs-block__action--desktop"
>
<BaseButton
v-if="tab.hasButton && activeTab === tab.name"
:text="tab.textButton"
custom-class="tabs-block__action-btn"
@click="onActionClick(tab)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</template>
</div>
<template v-for="tab in tabs">
<div
v-if="tab.hasButton"
:key="`mbtn-${tab.name}`"
class="tabs-block__action tabs-block__action--mobile"
>
<BaseButton
v-if="tab.hasButton && activeTab === tab.name"
:text="tab.textButton"
custom-class="tabs-block__action-btn"
@click="onActionClick(tab)"
>
<template #appendIcon>
<SvgIcon name="arrow-left" :size="16" color="#fff" />
</template>
</BaseButton>
</div>
</template>
<div class="tabs-block__content"> <div class="tabs-block__content">
<slot :name="activeTab" /> <slot :name="activeTab" />
</div> </div>
@@ -26,7 +64,7 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({ const props = defineProps({
@@ -51,39 +89,88 @@ const onChange = (name) => {
emit('update:modelValue', name) emit('update:modelValue', name)
emit('change-tab', name) emit('change-tab', name)
} }
const onActionClick = (tab) => {
tab.buttonAction?.(tab)
emit('button-click', tab)
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tabs-block { .tabs-block {
&__nav { &__nav {
display: flex; display: flex;
gap: 0.5rem; gap: 0.625rem;
border-block-end: 1px solid #eee;
margin-bottom: 1rem; margin-bottom: 1rem;
overflow: auto hidden;
} }
&__btn { &__tabs {
display: inline-flex; flex: 1;
align-items: center; display: flex;
gap: 0.375rem; gap: 0.625rem;
padding: 0.625rem 1rem; }
&__tab {
flex: 1;
border: none; border: none;
background: transparent; background: transparent;
padding: 0;
cursor: pointer; cursor: pointer;
font-family: var(--font-family-fa); transition: opacity 0.15s ease;
font-size: 0.875rem; opacity: 0.6;
color: #848484;
border-block-end: 2px solid transparent;
margin-block-end: -1px;
white-space: nowrap;
&--active { &--active {
color: var(--color-primary); opacity: 1;
border-color: var(--color-primary);
} }
} }
&__tab-card {
display: flex;
align-items: center;
gap: 0.375rem;
background: rgba(255, 255, 255, 80%);
box-shadow: 0 10px 15px -3px rgba(241, 241, 241, 100%);
padding: 0.775rem;
border-radius: 0.75rem;
}
&__tab-label {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.875rem;
color: #4b4b4b;
}
&__action {
display: flex;
align-items: stretch;
&--desktop {
display: none;
min-width: fit-content;
// margin-block-end: 0.625rem;
@media (min-width: 768px) {
display: flex;
}
}
&--mobile {
display: flex;
margin-block-end: 0.625rem;
@media (min-width: 768px) {
display: none;
}
}
}
&__action-btn {
height: 100%;
min-width: fit-content;
}
&__content { &__content {
padding-block: 0.5rem; padding-block: 0.5rem;
} }
@@ -0,0 +1,95 @@
<template>
<div class="video-player">
<video
v-if="src"
ref="player"
:src="src"
:poster="poster || ''"
controls
playsinline
class="video-player__media"
@timeupdate="onTimeUpdate"
/>
<div v-else class="video-player__placeholder">
<SvgIcon name="paper-plane-right" :size="48" color="rgba(0, 112, 116, 0.31)" />
<p>ویدیویی برای نمایش وجود ندارد</p>
</div>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const props = defineProps({
src: { type: String, default: '' },
poster: { type: String, default: '' },
videoId: { type: [String, Number], default: '' },
})
const player = ref(null)
const storageKey = () => (props.videoId ? `video_progress_${props.videoId}` : '')
onMounted(() => {
if (!player.value || !props.videoId) return
const saved = localStorage.getItem(storageKey())
if (saved) {
const t = Number.parseFloat(saved)
if (Number.isFinite(t)) player.value.currentTime = t
}
})
const onTimeUpdate = () => {
if (!player.value || !props.videoId) return
try {
localStorage.setItem(storageKey(), String(player.value.currentTime))
} catch {
/* storage unavailable */
}
}
onBeforeUnmount(() => {
if (player.value) {
player.value.pause()
player.value.src = ''
}
})
</script>
<style lang="scss" scoped>
.video-player {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 1.5rem;
overflow: hidden;
background: #000;
display: flex;
align-items: center;
justify-content: center;
&__media {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
height: 100%;
background: rgba(0, 112, 116, 4%);
color: #007074;
font-family: var(--font-family-fa);
font-size: 0.875rem;
}
&__placeholder p {
margin: 0;
}
}
</style>
@@ -0,0 +1,143 @@
<template>
<div class="activity-card">
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="activity-card__corner" />
<div class="activity-card__thumb" :class="{ 'activity-card__thumb--circle': circle }">
<img v-if="avatarUrl" :src="avatarUrl" :alt="title" class="activity-card__thumb-img" />
<SvgIcon v-else :name="thumbIcon" :size="'1.5rem'" color="#848484" />
</div>
<div class="activity-card__body">
<p class="activity-card__title">{{ title }}</p>
<div class="activity-card__meta">
<SvgIcon :name="metaIcon" :size="'0.875rem'" class="activity-card__meta-icon" />
<div class="activity-card__rows">
<p v-for="(row, i) in meta" :key="i" class="activity-card__row">
<span class="activity-card__row-label">{{ row.label }}</span>
<span
class="activity-card__row-value"
:class="{ 'activity-card__row-value--num': row.numeric }"
>
{{ row.value }}
</span>
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({
title: { type: String, default: '' },
/** rows of { label, value, numeric? } */
meta: { type: Array, default: () => [] },
/** @type {import('vue').PropType<import('@/components/icons/icon-names').IconName>} */
metaIcon: { type: String, default: 'calendar' },
/** thumbnail glyph shown when there is no avatarUrl */
thumbIcon: { type: String, default: 'play' },
/** render the thumbnail as a circular avatar instead of a rounded square */
circle: { type: Boolean, default: false },
avatarUrl: { type: String, default: '' },
})
</script>
<style lang="scss" scoped>
.activity-card {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
min-height: 5.9375rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.9375rem;
&__corner {
position: absolute;
inset-block-start: 0.75rem;
inset-inline-end: 1rem;
}
&__thumb {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3.6875rem;
height: 3.6875rem;
background: #f4f4f4;
border-radius: 0.9375rem;
overflow: hidden;
&--circle {
border-radius: 50%;
}
}
&__thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
&__body {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.625rem;
text-align: start;
min-width: 0;
}
&__title {
margin: 0;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.875rem;
color: #4b4b4b;
}
&__meta {
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 0.375rem;
}
&__meta-icon {
order: 2;
margin-top: 0.125rem;
}
&__rows {
order: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
&__row {
display: flex;
align-items: center;
gap: 0.375rem;
margin: 0;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.6875rem;
color: #4b4b4b;
}
&__row-value {
&--num {
font-family: var(--font-family-number-fa);
direction: ltr;
}
}
}
</style>
@@ -0,0 +1,65 @@
<template>
<div class="activity-group">
<div class="activity-group__pill">
<SvgIcon :name="pillIcon" :size="'1.125rem'" class="activity-group__pill-icon" />
<span class="activity-group__pill-label">{{ pillLabel }}</span>
</div>
<DashboardActivityCard
:title="title"
:meta="meta"
:meta-icon="metaIcon"
:thumb-icon="thumbIcon"
:circle="circle"
:avatar-url="avatarUrl"
/>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
import DashboardActivityCard from '@/components/dashboard/DashboardActivityCard.vue'
defineProps({
/** @type {import('vue').PropType<import('@/components/icons/icon-names').IconName>} */
pillIcon: { type: String, default: 'shopping-bag' },
pillLabel: { type: String, default: '' },
title: { type: String, default: '' },
meta: { type: Array, default: () => [] },
metaIcon: { type: String, default: 'calendar' },
thumbIcon: { type: String, default: 'play' },
circle: { type: Boolean, default: false },
avatarUrl: { type: String, default: '' },
})
</script>
<style lang="scss" scoped>
.activity-group {
display: flex;
flex-direction: column;
gap: 1rem;
&__pill {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.9375rem;
}
&__pill-icon {
order: 2;
}
&__pill-label {
order: 1;
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.125rem;
color: #4b4b4b;
}
}
</style>
@@ -0,0 +1,99 @@
<template>
<div class="calendar">
<p class="calendar__title">{{ monthLabel }}</p>
<div class="calendar__week">
<div
v-for="(d, i) in days"
:key="i"
class="calendar__day"
:class="{ 'calendar__day--active': d.selected }"
>
<span v-if="d.marker" class="calendar__marker" />
<span class="calendar__weekday">{{ d.weekday }}</span>
<span class="calendar__date">{{ d.day }}</span>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
monthLabel: { type: String, default: '' },
/** ordered right-to-left: { weekday, day, selected?, marker? } */
days: { type: Array, default: () => [] },
})
</script>
<style lang="scss" scoped>
.calendar {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.125rem 1rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.9375rem;
&__title {
margin: 0;
text-align: start;
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.25rem;
color: #000;
}
&__week {
display: flex;
justify-content: space-between;
gap: 0.125rem;
}
&__day {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.125rem;
border-radius: 999px;
&--active {
background: var(--color-secondary);
box-shadow: 0 9px 9.75px rgba(0, 112, 116, 19%);
.calendar__weekday,
.calendar__date {
color: #fff;
}
}
}
&__marker {
position: absolute;
inset-block-start: 0.125rem;
inset-inline-start: 0.25rem;
width: 0.25rem;
height: 0.25rem;
border-radius: 50%;
background: var(--color-secondary);
}
&__weekday {
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.625rem;
color: #828282;
white-space: nowrap;
}
&__date {
font-family: var(--font-family-number-fa);
font-weight: 700;
font-size: 0.875rem;
color: #717171;
}
}
</style>
@@ -0,0 +1,134 @@
<template>
<div class="profile-card">
<div class="profile-card__avatar">
<img v-if="avatarUrl" :src="avatarUrl" :alt="name" />
</div>
<p class="profile-card__name">{{ name }}</p>
<div class="profile-card__location">
<SvgIcon name="map-pin-simple-area" :size="'0.9375rem'" />
<span>{{ location }}</span>
</div>
<div class="profile-card__stats">
<div v-for="(stat, i) in stats" :key="i" class="profile-card__stat">
<span class="profile-card__stat-label">{{ stat.label }}</span>
<span
class="profile-card__stat-value"
:class="{ 'profile-card__stat-value--text': !isNumeric(stat.value) }"
>
{{ stat.value }}
<span v-if="stat.unit" class="profile-card__stat-unit">{{ stat.unit }}</span>
</span>
</div>
</div>
</div>
</template>
<script setup>
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({
name: { type: String, default: '' },
location: { type: String, default: '' },
avatarUrl: { type: String, default: '' },
/** mini-stats of { label, value, unit? } */
stats: { type: Array, default: () => [] },
})
const isNumeric = (value) => /^[\d۰-۹]+$/.test(String(value ?? '').trim())
</script>
<style lang="scss" scoped>
.profile-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem 1.25rem 1.5rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.9375rem;
&__avatar {
width: 7.75rem;
height: 7.75rem;
border-radius: 50%;
background: #f4f4f4;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__name {
margin: 0;
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.25rem;
color: #000;
}
&__location {
display: flex;
align-items: center;
gap: 0.25rem;
font-family: var(--font-family-fa);
font-weight: 400;
font-size: 0.875rem;
color: #989898;
}
&__stats {
display: flex;
justify-content: space-between;
align-items: flex-start;
width: 100%;
margin-top: 0.5rem;
}
&__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex: 1;
}
&__stat-label {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.6875rem;
color: #a7a7a7;
}
&__stat-value {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
font-family: var(--font-family-number-fa);
font-weight: 400;
font-size: 1.5625rem;
color: #818181;
text-align: center;
&--text {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 0.9375rem;
line-height: 1.5;
}
}
&__stat-unit {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 0.75rem;
color: #929292;
}
}
</style>
@@ -0,0 +1,132 @@
<template>
<component
:is="to ? RouterLink : 'div'"
:to="to || undefined"
class="stat-card"
:class="{ 'stat-card--linked': to }"
>
<SvgIcon name="card-corner-arrow" :size="'1.5rem'" class="stat-card__corner" />
<div class="stat-card__head">
<SvgIcon :name="icon" :size="'1.0625rem'" class="stat-card__head-icon" />
<span class="stat-card__label">{{ label }}</span>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ value }}</span>
<div class="stat-card__meta">
<span class="stat-card__unit">{{ unit }}</span>
<span class="stat-card__subtitle">{{ subtitle }}</span>
</div>
</div>
</component>
</template>
<script setup>
import { RouterLink } from 'vue-router'
import SvgIcon from '@/components/icons/SvgIcon.vue'
defineProps({
/** @type {import('vue').PropType<import('@/components/icons/icon-names').IconName>} */
icon: { type: String, required: true },
label: { type: String, default: '' },
value: { type: [String, Number], default: '' },
unit: { type: String, default: '' },
subtitle: { type: String, default: '' },
to: { type: [String, Object], default: null },
})
</script>
<style lang="scss" scoped>
.stat-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.125rem 1.25rem;
min-height: 8.75rem;
background: rgba(255, 255, 255, 58%);
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
border-radius: 0.9375rem;
color: inherit;
text-decoration: none;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&--linked {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 9px 24px rgba(0, 0, 0, 7%);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
}
&__corner {
position: absolute;
inset-block-start: 0.875rem;
inset-inline-end: 1.125rem;
}
&__head {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.375rem;
}
&__head-icon {
order: 2;
}
&__label {
order: 1;
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.125rem;
color: #4b4b4b;
}
&__body {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.625rem;
margin-top: auto;
}
&__value {
font-family: var(--font-family-number-fa);
font-weight: 700;
font-size: 4rem;
line-height: 1;
color: var(--color-primary);
text-shadow: 0 5.25px 8.625px rgba(243, 102, 117, 18%);
}
&__meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
&__unit {
font-family: var(--font-family-fa);
font-weight: 300;
font-size: 1.125rem;
color: #6e6e6e;
}
&__subtitle {
font-family: var(--font-family-fa);
font-weight: 500;
font-size: 1.25rem;
color: #6e6e6e;
}
}
</style>
+124
View File
@@ -0,0 +1,124 @@
<template>
<div class="badge-select">
<label v-if="label" class="badge-select__label">{{ label }}</label>
<div class="badge-select__chips" :class="`badge-select__chips--${size}`">
<button
v-for="option in options"
:key="option[optionValue]"
type="button"
class="badge-select__chip"
:class="[
`badge-select__chip--${variant}`,
{ 'badge-select__chip--selected': modelValue === option[optionValue] },
]"
:disabled="disabled"
@click="select(option)"
>
{{ option[optionLabel] }}
</button>
</div>
<div v-if="error" class="badge-select__error">{{ error }}</div>
</div>
</template>
<script setup>
const props = defineProps({
modelValue: { type: [String, Number], default: null },
label: { type: String, default: '' },
options: { type: Array, default: () => [] },
optionLabel: { type: String, default: 'label' },
optionValue: { type: String, default: 'value' },
variant: { type: String, default: 'neutral' },
size: { type: String, default: 'md' },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'change'])
const select = (option) => {
if (props.disabled) return
const value = option[props.optionValue]
emit('update:modelValue', value)
emit('change', value)
}
</script>
<style lang="scss" scoped>
.badge-select {
width: 100%;
margin-bottom: 0.75rem;
&__label {
display: flex;
margin-bottom: 0.375rem;
font-family: var(--font-family-fa);
font-weight: 300;
line-height: 1.5rem;
font-size: 0.875rem;
color: var(--color-prim-gray);
}
&__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
&__chip {
border: 1px solid #e8e8e8;
border-radius: 9999px;
background: transparent;
font-family: var(--font-family-fa);
color: #b5b5b5;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
&:hover:enabled {
color: #7a7a7a;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--selected {
background: #fff;
border-color: #f0f0f0;
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 6%);
color: #4b4b4b;
font-weight: 500;
}
&--primary#{&}--selected {
color: var(--color-primary);
border-color: var(--color-primary);
}
}
&__chips--sm .badge-select__chip {
padding: 0.25rem 0.875rem;
font-size: 0.7rem;
}
&__chips--md .badge-select__chip {
padding: 0.375rem 1.125rem;
font-size: 0.8rem;
}
&__chips--lg .badge-select__chip {
padding: 0.5rem 1.375rem;
font-size: 0.875rem;
}
&__error {
margin-top: 0.25rem;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-error);
}
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<button
type="button"
class="checkbox-field"
:class="{
'checkbox-field--selected': modelValue,
'checkbox-field--disabled': disabled,
}"
:disabled="disabled"
@click="onToggle"
>
<span class="checkbox-field__box" />
<span v-if="label" class="checkbox-field__label">{{ label }}</span>
<slot />
</button>
</template>
<script setup>
const props = defineProps({
modelValue: { type: Boolean, default: false },
label: { type: String, default: '' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
const onToggle = () => {
if (props.disabled) return
const next = !props.modelValue
emit('update:modelValue', next)
emit('change', next)
}
</script>
<style lang="scss" scoped>
.checkbox-field {
display: inline-flex;
align-items: center;
gap: 0.625rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
cursor: pointer;
font-family: var(--font-family-fa);
transition: opacity 0.15s ease;
&:hover {
opacity: 0.85;
}
&--disabled {
cursor: not-allowed;
opacity: 0.6;
}
&--selected .checkbox-field__box {
background: #999;
border-color: #999;
}
&__box {
width: 0.95rem;
height: 0.95rem;
border-radius: 0.3rem;
border: 0.75px solid #c2c2c2;
background: transparent;
flex-shrink: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
&__label {
font-weight: 400;
font-size: 0.95rem;
line-height: 1.4;
color: #5d5d5d;
white-space: nowrap;
}
}
</style>
+437 -88
View File
@@ -4,7 +4,7 @@
<div <div
:id="inputId" :id="inputId"
ref="referenceEl" ref="referenceRef"
tabindex="0" tabindex="0"
class="datepicker-field__trigger" class="datepicker-field__trigger"
:class="{ :class="{
@@ -14,7 +14,7 @@
@click="toggle" @click="toggle"
> >
<span class="datepicker-field__value">{{ displayValue }}</span> <span class="datepicker-field__value">{{ displayValue }}</span>
<SvgIcon name="calendar" :size="20" class="datepicker-field__icon" /> <SvgIcon name="calendar" :size="20" color="#DDDDDD" class="datepicker-field__icon" />
</div> </div>
<div v-if="error" class="datepicker-field__error" :class="{ 'animate-shake': vibration }"> <div v-if="error" class="datepicker-field__error" :class="{ 'animate-shake': vibration }">
@@ -22,10 +22,10 @@
</div> </div>
<Teleport to="body"> <Teleport to="body">
<Transition name="fade"> <transition name="fade">
<div <div
v-show="isOpen" v-show="isOpen"
ref="floatingEl" ref="floatingRef"
class="date-picker-dropdown datepicker-field__dropdown" class="date-picker-dropdown datepicker-field__dropdown"
:style="floatingStyles" :style="floatingStyles"
> >
@@ -34,38 +34,45 @@
locale="fa" locale="fa"
inline inline
editable editable
:min="min" compact-time
:max="max" :min="jalaliMin"
:max="jalaliMax"
:type="type" :type="type"
:simple="simple" :simple="simple"
:auto-submit="type === 'date'" :range="range"
:auto-submit="false"
:time-picker="type === 'datetime'"
:compact-time="type === 'datetime'"
:format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'" :format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'"
:display-format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'" :display-format="type === 'datetime' ? 'jYYYY/jMM/jDD HH:mm' : 'jYYYY/jMM/jDD'"
:confirm="type === 'datetime'"
@update:model-value="onSelect" @update:model-value="onSelect"
/> />
</div> </div>
</Transition> </transition>
</Teleport> </Teleport>
</div> </div>
</template> </template>
<script setup> <script>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import DatePicker from 'vue3-persian-datetime-picker'
import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import DatePicker from 'vue3-persian-datetime-picker'
import { computePosition, autoUpdate, offset, shift, flip, size } from '@floating-ui/dom'
import { import {
formatJalaaliDate, JalaliToGregorianString,
formatJalaaliDateTime, gregorianToJalaliString,
jalaaliStringToIsoDate, gregorianToJalaliStringLong,
jalaaliStringToIsoDateTime, JalaliToGregorianStringWithTime,
} from '@/utils/date-utils' } from '@/utils/date-convertor'
let uid = 0 let uid = 0
const nextUid = () => `datepicker-field-${++uid}` const nextUid = () => `datepicker-field-${++uid}`
const props = defineProps({ export default {
name: 'DatePickerField',
components: { DatePicker, SvgIcon },
props: {
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
name: { type: String, default: '' }, name: { type: String, default: '' },
label: { type: String, default: '' }, label: { type: String, default: '' },
@@ -77,49 +84,119 @@ const props = defineProps({
max: { type: String, default: undefined }, max: { type: String, default: undefined },
type: { type: String, default: 'date' }, type: { type: String, default: 'date' },
simple: { type: Boolean, default: false }, simple: { type: Boolean, default: false },
}) range: { type: [Boolean, Array], default: false },
autoSubmit: { type: Boolean, default: true },
},
const emit = defineEmits(['update:modelValue', 'change']) emits: ['update:modelValue', 'change'],
const inputId = computed(() => props.name || nextUid()) data() {
const referenceEl = ref(null) return {
const floatingEl = ref(null) inputId: this.name || nextUid(),
const isOpen = ref(false) isOpen: false,
const internalValue = ref(null) internalValue: null,
const floatingStyles = ref({ position: 'absolute', top: '0px', left: '0px' }) cleanup: null,
let cleanup = null floatingStyles: {
position: 'absolute',
const displayValue = computed(() => internalValue.value || props.placeholder) top: '0px',
left: '0px',
const syncFromProp = () => { },
const v = props.modelValue confirmHandler: null,
if (v && typeof v === 'string') { mutationObserver: null,
internalValue.value =
props.type === 'datetime' ? formatJalaaliDateTime(v) : formatJalaaliDate(v)
} else {
internalValue.value = null
} }
} },
watch(() => props.modelValue, syncFromProp, { immediate: true }) computed: {
displayValue() {
return this.internalValue || this.placeholder
},
jalaliMin() {
if (!this.min) return
if (this.type === 'datetime') {
return gregorianToJalaliStringLong(this.min, '/', false).trim().replace('، ', ' ')
}
return gregorianToJalaliString(this.min, '/', false)
},
jalaliMax() {
if (!this.max) return
if (this.type === 'datetime') {
return gregorianToJalaliStringLong(this.max, '/', false).trim().replace('، ', ' ')
}
return gregorianToJalaliString(this.max, '/', false)
},
},
const onSelect = (jalaliValue) => { watch: {
if (!jalaliValue) return modelValue: {
internalValue.value = jalaliValue immediate: true,
const gregorian = handler(val) {
props.type === 'datetime' this.syncFromModel(val)
? jalaaliStringToIsoDateTime(jalaliValue) },
: jalaaliStringToIsoDate(jalaliValue) },
emit('update:modelValue', gregorian) },
emit('change', gregorian)
if (props.type === 'date') close()
}
const mountFloating = () => { beforeUnmount() {
const reference = referenceEl.value this.cleanup?.()
const floating = floatingEl.value this.removeConfirmButtonListener()
document.removeEventListener('mousedown', this.handleOutside)
},
methods: {
syncFromModel(val) {
if (val && typeof val === 'string') {
this.internalValue =
this.type === 'datetime'
? gregorianToJalaliStringLong(val, '/', false)
: gregorianToJalaliString(val, '/', false)
} else {
this.internalValue = null
}
},
toggle() {
if (this.disabled) return
this.isOpen ? this.close() : this.open()
},
open() {
this.isOpen = true
this.$nextTick(() => {
this.initFloating()
if (this.type === 'datetime') {
this.$nextTick(() => {
this.attachConfirmButtonListener()
})
}
})
},
close() {
this.isOpen = false
this.cleanup?.()
this.cleanup = null
this.removeConfirmButtonListener()
},
removeConfirmButtonListener() {
if (this.confirmHandler) {
this.confirmHandler.element.removeEventListener('click', this.confirmHandler.handler)
delete this.confirmHandler.element.dataset.confirmListener
this.confirmHandler = null
}
if (this.mutationObserver) {
this.mutationObserver.disconnect()
this.mutationObserver = null
}
},
initFloating() {
const reference = this.$refs.referenceRef
const floating = this.$refs.floatingRef
if (!reference || !floating) return if (!reference || !floating) return
cleanup = autoUpdate(reference, floating, () => {
this.cleanup = autoUpdate(reference, floating, () => {
computePosition(reference, floating, { computePosition(reference, floating, {
placement: 'bottom-start', placement: 'bottom-start',
middleware: [ middleware: [
@@ -128,52 +205,184 @@ const mountFloating = () => {
shift({ padding: 8 }), shift({ padding: 8 }),
size({ size({
apply({ rects }) { apply({ rects }) {
Object.assign(floating.style, { minWidth: `${rects.reference.width}px` }) Object.assign(floating.style, {
minWidth: `${rects.reference.width}px`,
})
}, },
}), }),
], ],
}).then(({ x, y }) => { }).then(({ x, y }) => {
floatingStyles.value = { position: 'absolute', left: `${x}px`, top: `${y}px` } Object.assign(this.floatingStyles, {
left: `${x}px`,
top: `${y}px`,
})
}) })
}) })
document.addEventListener('mousedown', handleOutsideClick)
}
const unmountFloating = () => { document.addEventListener('mousedown', this.handleOutside)
cleanup?.() },
cleanup = null
document.removeEventListener('mousedown', handleOutsideClick)
}
const handleOutsideClick = (event) => { handleOutside(e) {
const reference = referenceEl.value const r = this.$refs.referenceRef
const floating = floatingEl.value const f = this.$refs.floatingRef
if ( if (r?.contains(e.target) || f?.contains(e.target)) return
reference && this.close()
!reference.contains(event.target) && document.removeEventListener('mousedown', this.handleOutside)
floating && },
!floating.contains(event.target)
) { onSelect(jalaliValue) {
close() if (!jalaliValue) return
if (this.type === 'datetime') {
const gregorian = JalaliToGregorianStringWithTime(jalaliValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.internalValue = jalaliValue
} else {
const gregorian = JalaliToGregorianString(jalaliValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.internalValue = jalaliValue
this.close()
} }
} },
const open = () => { attachConfirmButtonListener() {
isOpen.value = true const findAndAttach = () => {
setTimeout(mountFloating, 0) const dropdown = this.$refs.floatingRef
} if (!dropdown) return false
let confirmBtn = dropdown.querySelector('.vpd-actions')
if (confirmBtn) {
const allButtons = confirmBtn.querySelectorAll('button')
for (const btn of allButtons) {
if (btn.innerHTML.includes('svg') || btn.innerText.includes('تایید')) {
confirmBtn = btn
break
}
}
}
const close = () => { if (confirmBtn && !Object.hasOwn(confirmBtn.dataset, 'confirmListener')) {
isOpen.value = false const handler = (e) => {
unmountFloating() e.preventDefault()
} e.stopPropagation()
this.handleConfirm()
}
const toggle = () => { confirmBtn.addEventListener('click', handler, true)
if (props.disabled) return confirmBtn.dataset.confirmListener = 'true'
isOpen.value ? close() : open() this.confirmHandler = { element: confirmBtn, handler }
} return true
}
return false
}
onBeforeUnmount(() => unmountFloating()) if (!findAndAttach()) {
const timeouts = [100, 300, 500, 1000]
timeouts.forEach((delay) => {
setTimeout(() => {
if (this.isOpen) {
findAndAttach()
}
}, delay)
})
}
this.setupMutationObserver()
},
setupMutationObserver() {
const dropdown = this.$refs.floatingRef
if (!dropdown) return
this.mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
setTimeout(() => {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (this.isConfirmButton(node)) {
this.attachListenerToButton(node)
}
const btns = node.querySelectorAll ? node.querySelectorAll('button') : []
for (const btn of btns) {
if (this.isConfirmButton(btn)) {
this.attachListenerToButton(btn)
}
}
}
}
}, 50)
}
}
})
this.mutationObserver.observe(dropdown, {
childList: true,
subtree: true,
})
},
isConfirmButton(element) {
if (!element || !element.tagName) return false
if (
element.classList?.contains('vpd-confirm-btn') ||
element.classList?.contains('vpd-action-btn')
) {
return true
}
if (element.innerText?.includes('تایید')) {
return true
}
if (element.innerHTML?.includes('svg')) {
const svgPaths = element.innerHTML.match(/d="[^"]*"/g) || []
const hasCheckIcon = svgPaths.some(
(path) =>
path.includes('M20 6L9 17l-5-5') ||
path.includes('M9 16.17L4.83 12') ||
path.includes('M5 13l4 4L19 7')
)
return hasCheckIcon
}
return false
},
attachListenerToButton(btn) {
if (Object.hasOwn(btn.dataset, 'confirmListener')) return
const handler = (e) => {
e.preventDefault()
e.stopPropagation()
this.handleConfirm()
}
btn.addEventListener('click', handler, true)
btn.dataset.confirmListener = 'true'
this.confirmHandler = { element: btn, handler }
},
handleConfirm() {
if (!this.internalValue) return
try {
const gregorian = JalaliToGregorianStringWithTime(this.internalValue)
this.$emit('update:modelValue', gregorian)
this.$emit('change', gregorian)
this.close()
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error in handleConfirm:', error)
}
},
},
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -255,3 +464,143 @@ onBeforeUnmount(() => unmountFloating())
} }
} }
</style> </style>
<style>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.date-picker-dropdown .vpd-main .vpd-input-group {
display: none;
}
.date-picker-dropdown .vpd-wrapper .vpd-container {
margin: 0 !important;
font-family: iran-yekan;
width: 100%;
}
.date-picker-dropdown .vpd-wrapper .vpd-content {
width: auto;
border-radius: 8px;
}
.date-picker-dropdown .vpd-wrapper .vpd-header .vpd-date {
display: none;
}
.date-picker-dropdown .vpd-wrapper .vpd-header {
background-color: transparent !important;
padding: 5px;
}
.date-picker-dropdown .vpd-wrapper .vpd-header .vpd-year-label {
color: #5f5f5f;
text-align: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body {
overflow-x: hidden;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-controls button {
display: flex;
align-items: center;
justify-content: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-clearfix.vpd-week {
display: flex;
justify-content: space-between;
align-items: center;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-clearfix.vpd-week
.vpd-weekday {
width: auto;
float: none;
clear: both;
padding: 0 12px;
}
@media screen and (max-width: 400px) {
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-clearfix.vpd-week,
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days {
padding: 0 5px;
}
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.direction-prev
.vpd-clearfix {
display: flex;
justify-content: space-between;
align-items: center;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days {
height: fit-content !important;
padding: 0 12px;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-clearfix.vpd-month .vpd-days .vpd-day {
width: 35px;
height: 35px;
float: right;
line-height: 35px;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day:not([disabled='true']).vpd-selected
.vpd-day-effect,
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day:not([disabled='true']):hover
.vpd-day-effect {
background-color: #f36675 !important;
}
.date-picker-dropdown
.vpd-wrapper
.vpd-body
.vpd-clearfix.vpd-month
.vpd-days
.vpd-day
.vpd-day-effect {
width: 35px;
height: 35px;
top: -2px;
left: 0;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-close-addon {
background-color: #f5f5f5;
border-radius: 5px;
}
.date-picker-dropdown .vpd-wrapper .vpd-body .vpd-month-label {
width: auto;
}
</style>
+25 -14
View File
@@ -9,8 +9,11 @@
@dragleave="isDragging = false" @dragleave="isDragging = false"
@drop="handleDrop" @drop="handleDrop"
> >
<SvgIcon name="upload" :size="32" class="file-uploader__upload-icon" /> <img :src="uploadSimple" class="file-uploader__upload-icon" />
<span class="file-uploader__upload-text">{{ uploadText }}</span> <span class="file-uploader__upload-text">
<span class="file-uploader__upload-text--light">بارگذاری</span>
<span class="file-uploader__upload-text--bold">فایل ضمیمــــه</span>
</span>
<input <input
type="file" type="file"
class="file-uploader__input" class="file-uploader__input"
@@ -49,21 +52,22 @@
@click="removeFile(index, file)" @click="removeFile(index, file)"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="16" /> <SvgIcon name="close" color="red" :size="16" />
</template> </template>
</CircleButton> </CircleButton>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="file-uploader__empty">هیچ فایلی آپلود نشده است</div> <!-- <div v-else class="file-uploader__empty">هیچ فایلی آپلود نشده است</div> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { ref } from 'vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
import uploadSimple from '@/assets/images/upload-simple.png'
const props = defineProps({ const props = defineProps({
modelValue: { type: Array, default: () => [] }, modelValue: { type: Array, default: () => [] },
@@ -77,10 +81,6 @@ const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
const isDragging = ref(false) const isDragging = ref(false)
const uploadText = computed(() =>
props.modelValue.length === 0 ? 'فایل جدید اضافه کنید' : 'فایل دیگری اضافه کنید'
)
const handleFileSelect = (event) => { const handleFileSelect = (event) => {
const files = [...event.target.files] const files = [...event.target.files]
emitFiles(files) emitFiles(files)
@@ -140,14 +140,15 @@ const formatFileSize = (bytes) => {
} }
&__dropzone { &__dropzone {
border: 2px dashed #d1d5db; background-color: #f4f4f4;
border-radius: 0.5rem; border-radius: 1rem;
padding: 1rem; padding: 1rem;
text-align: center; text-align: center;
transition: border-color 0.2s; transition: border-color 0.2s;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center; align-items: center;
justify-content: center;
gap: 0.5rem; gap: 0.5rem;
&:hover, &:hover,
@@ -161,10 +162,20 @@ const formatFileSize = (bytes) => {
} }
&__upload-text { &__upload-text {
display: flex;
flex-direction: column;
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500;
color: #4b5563; color: #4b5563;
&--light {
text-align: right;
font-weight: 400;
}
&--bold {
font-weight: 600;
}
} }
&__input { &__input {
+11 -12
View File
@@ -7,7 +7,7 @@
class="image-cropper__upload-btn" class="image-cropper__upload-btn"
@click="triggerUpload" @click="triggerUpload"
> >
<SvgIcon name="upload" :size="64" /> <SvgIcon name="upload" color="" :size="64" />
</button> </button>
<div v-if="isCropping && image" class="image-cropper__crop-stage"> <div v-if="isCropping && image" class="image-cropper__crop-stage">
@@ -69,7 +69,7 @@
</div> </div>
<div class="image-cropper__hint"> <div class="image-cropper__hint">
<SvgIcon name="warning" class="image-cropper__hint-icon" /> <SvgIcon :name="helperIcon" color="#B8B8B8" :size="20" />
<p>{{ helperText }}</p> <p>{{ helperText }}</p>
</div> </div>
</div> </div>
@@ -83,23 +83,23 @@
</template> </template>
<script setup> <script setup>
import 'vue-advanced-cropper/dist/style.css'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
import { markRaw, onBeforeUnmount, ref, watch } from 'vue' import { markRaw, onBeforeUnmount, ref, watch } from 'vue'
import { Cropper, CircleStencil } from 'vue-advanced-cropper' import { Cropper, CircleStencil } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: [Object, String], default: null }, modelValue: { type: [Object, String], default: null },
name: { type: String, default: '' }, name: { type: String, default: '' },
vibration: { type: Boolean, default: false }, vibration: { type: Boolean, default: false },
bgColor: { type: String, default: '#f3f4f6' }, bgColor: { type: String, default: '#f3f4f6' },
maxSize: { type: Number, default: 250 * 1024 }, maxSize: { type: Number, default: 5 * 1024 * 1024 },
helperText: { helperText: {
type: String, type: String,
default: 'سایز تصویر رسمی 3x4 و حجم تصویر بیشتر از 250 کیلو بایت نباشد.', default: 'سایز تصویر رسمی 3x4 و حجم تصویر بیشتر از ۵ مگابایت نباشد.',
}, },
helperIcon: { type: String, default: 'warning' },
error: { type: String, default: '' }, error: { type: String, default: '' },
}) })
@@ -144,7 +144,7 @@ const onFileChange = (event) => {
if (!file) return if (!file) return
if (props.maxSize > 0 && file.size > props.maxSize) { if (props.maxSize > 0 && file.size > props.maxSize) {
emit('error', 'حجم تصویر نباید بیشتر از ۲۵۰ کیلوبایت باشد.') emit('error', 'حجم تصویر نباید بیشتر از ۵ مگابایت باشد.')
event.target.value = null event.target.value = null
return return
} }
@@ -315,7 +315,7 @@ onBeforeUnmount(cleanup)
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.75rem; gap: 0.25rem;
p { p {
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
@@ -325,8 +325,7 @@ onBeforeUnmount(cleanup)
margin: 0; margin: 0;
@media (min-width: 1024px) { @media (min-width: 1024px) {
font-size: 0.75rem; font-size: 0.7rem;
line-height: 1.75rem;
} }
} }
} }
+231
View File
@@ -0,0 +1,231 @@
<template>
<div class="image-uploader">
<div class="image-uploader__stage">
<div v-if="previewUrl" class="image-uploader__preview">
<img :src="previewUrl" :alt="fileName || 'تصویر'" />
</div>
<button
v-else
type="button"
class="image-uploader__main-btn"
:disabled="disabled"
@click="triggerFilePicker"
>
<SvgIcon name="upload" :size="48" color="var(--color-thd-gray)" />
</button>
<p v-if="fileName && previewUrl" class="image-uploader__name">{{ fileName }}</p>
</div>
<div v-if="previewUrl" class="image-uploader__actions">
<button
type="button"
class="image-uploader__btn"
:disabled="disabled"
title="جایگزینی تصویر"
@click="triggerFilePicker"
>
<SvgIcon name="upload" :size="20" color="var(--color-thd-gray)" />
</button>
<button type="button" class="image-uploader__btn" title="حذف" @click="clear">
<SvgIcon name="close" :size="20" color="var(--color-thd-gray)" />
</button>
</div>
<input
ref="fileInput"
type="file"
:accept="accept"
class="image-uploader__file-input"
@change="onFileSelected"
/>
<p v-if="error" class="image-uploader__error">{{ error }}</p>
</div>
</template>
<script setup>
import { toast } from 'vue3-toastify'
import { onBeforeUnmount, ref, watch } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
modelValue: { type: [Object, File, String, null], default: null },
accept: { type: String, default: 'image/*' },
maxSizeKb: { type: Number, default: 5120 },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const initialUrl =
typeof props.modelValue === 'string' ? props.modelValue : props.modelValue?.url || null
const previewUrl = ref(initialUrl)
const fileName = ref(
props.modelValue instanceof File ? props.modelValue.name : props.modelValue?.name || ''
)
const fileInput = ref(null)
const revokePreview = () => {
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl.value)
}
}
const setFile = (file) => {
revokePreview()
previewUrl.value = URL.createObjectURL(file)
fileName.value = file.name
emit('update:modelValue', file)
}
const triggerFilePicker = () => fileInput.value?.click()
const onFileSelected = (event) => {
const file = event.target?.files?.[0]
if (event.target) event.target.value = ''
if (!file) return
if (file.size > props.maxSizeKb * 1024) {
toast.error(`حجم فایل نباید بیشتر از ${props.maxSizeKb} کیلوبایت باشد.`)
return
}
setFile(file)
}
const clear = () => {
revokePreview()
previewUrl.value = null
fileName.value = ''
emit('update:modelValue', null)
}
watch(
() => props.modelValue,
(val) => {
if (val == null) {
revokePreview()
previewUrl.value = null
fileName.value = ''
}
}
)
onBeforeUnmount(revokePreview)
</script>
<style lang="scss" scoped>
.image-uploader {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #eee;
border-radius: 1.5rem;
min-height: 18rem;
justify-content: space-between;
&__stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.625rem;
width: 100%;
}
&__main-btn {
width: 8rem;
height: 8rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover:enabled {
transform: scale(1.02);
box-shadow: 0 4px 14px rgba(0, 0, 0, 8%);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&__preview {
width: 8rem;
height: 8rem;
border-radius: 1rem;
overflow: hidden;
background: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 6%);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__name {
max-width: 14rem;
margin: 0;
text-align: center;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: #5d5d5d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 6%);
transition: transform 0.15s ease;
&:hover:enabled {
transform: scale(1.05);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
&__file-input {
display: none;
}
&__error {
margin: 0;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-error);
}
}
</style>
+1 -2
View File
@@ -24,9 +24,8 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import TextField from '@/components/form/TextField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
+52 -14
View File
@@ -15,9 +15,19 @@
@click="toggle" @click="toggle"
> >
<span class="select-field__value">{{ selectedLabel }}</span> <span class="select-field__value">{{ selectedLabel }}</span>
<button
v-if="showClear"
type="button"
class="select-field__clear"
aria-label="پاک کردن"
@click.stop="clearSelection"
>
<SvgIcon name="close" :size="14" color="currentColor" />
</button>
<SvgIcon <SvgIcon
name="caret-down" name="caret-down"
:size="20" :size="20"
color="#DDDDDD"
class="select-field__caret" class="select-field__caret"
:class="{ 'select-field__caret--open': isOpen }" :class="{ 'select-field__caret--open': isOpen }"
/> />
@@ -74,9 +84,8 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
let uid = 0 let uid = 0
const nextUid = () => `select-field-${++uid}` const nextUid = () => `select-field-${++uid}`
@@ -86,10 +95,11 @@ const props = defineProps({
name: { type: String, default: '' }, name: { type: String, default: '' },
label: { type: String, default: '' }, label: { type: String, default: '' },
placeholder: { type: String, default: 'انتخاب کنید' }, placeholder: { type: String, default: 'انتخاب کنید' },
options: { type: Array, required: true }, options: { type: Array, default: () => [] },
optionLabel: { type: String, default: 'name' }, optionLabel: { type: String, default: 'name' },
optionValue: { type: String, default: 'id' }, optionValue: { type: String, default: 'id' },
multiple: { type: Boolean, default: false }, multiple: { type: Boolean, default: false },
clearable: { type: Boolean, default: true },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
error: { type: String, default: '' }, error: { type: String, default: '' },
vibration: { type: Boolean, default: false }, vibration: { type: Boolean, default: false },
@@ -119,16 +129,31 @@ const isSelected = (opt) => {
const selectedLabel = computed(() => { const selectedLabel = computed(() => {
const value = props.modelValue const value = props.modelValue
if (value === undefined || value === null || value === '') return props.placeholder if (value === undefined || value === null || value === '') return props.placeholder
const options = props.options ?? []
if (props.multiple && Array.isArray(value)) { if (props.multiple && Array.isArray(value)) {
const selected = props.options.filter((o) => value.includes(o[props.optionValue])) const selected = options.filter((o) => value.includes(o[props.optionValue]))
if (selected.length === 0) return props.placeholder if (selected.length === 0) return props.placeholder
if (selected.length <= 2) return selected.map((s) => s[props.optionLabel]).join('، ') if (selected.length <= 2) return selected.map((s) => s[props.optionLabel]).join('، ')
return `${selected.length} مورد انتخاب شده` return `${selected.length} مورد انتخاب شده`
} }
const found = props.options.find((o) => o[props.optionValue] === value) const found = options.find((o) => o[props.optionValue] === value)
return found ? found[props.optionLabel] : props.placeholder return found ? found[props.optionLabel] : props.placeholder
}) })
const hasValue = computed(() =>
props.multiple
? Array.isArray(props.modelValue) && props.modelValue.length > 0
: props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== ''
)
const showClear = computed(() => props.clearable && !props.disabled && hasValue.value)
const clearSelection = () => {
const cleared = props.multiple ? [] : null
emit('update:modelValue', cleared)
emit('change', cleared)
}
const select = (opt) => { const select = (opt) => {
if (props.disabled) return if (props.disabled) return
const value = opt[props.optionValue] const value = opt[props.optionValue]
@@ -242,10 +267,6 @@ onBeforeUnmount(() => unmountFloating())
transition: all 0.2s; transition: all 0.2s;
outline: none; outline: none;
&:focus {
box-shadow: 0 0 0 1px var(--color-validate);
}
&--error { &--error {
border-color: var(--color-error); border-color: var(--color-error);
@@ -278,6 +299,24 @@ onBeforeUnmount(() => unmountFloating())
} }
} }
&__clear {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-inline-end: 0.375rem;
border: none;
background: transparent;
color: var(--color-thd-gray);
cursor: pointer;
flex-shrink: 0;
transition: color 0.15s;
&:hover {
color: var(--color-error);
}
}
&__error { &__error {
margin-top: 0.25rem; margin-top: 0.25rem;
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
@@ -308,10 +347,6 @@ onBeforeUnmount(() => unmountFloating())
border: 1px solid var(--color-thd-gray); border: 1px solid var(--color-thd-gray);
border-radius: 0.75rem; border-radius: 0.75rem;
outline: none; outline: none;
&:focus {
box-shadow: 0 0 0 1px var(--color-validate);
}
} }
&__empty { &__empty {
@@ -336,12 +371,15 @@ onBeforeUnmount(() => unmountFloating())
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
width: 100%; width: 100%;
padding: 0.25rem 0.75rem; min-height: 2.5rem;
padding: 0.5rem 0.75rem;
border: none; border: none;
border-radius: 1.5rem; border-radius: 1.5rem;
background: transparent; background: transparent;
text-align: right; text-align: right;
font-family: var(--font-family-fa); font-family: var(--font-family-fa);
font-weight: 400;
line-height: 1.5;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s; transition: background-color 0.15s;
+1 -2
View File
@@ -116,7 +116,6 @@ defineExpose({ focus: () => inputEl.value?.focus() })
&:focus { &:focus {
border-color: var(--color-thd-gray); border-color: var(--color-thd-gray);
box-shadow: 0 0 0 1px var(--color-validate);
} }
} }
@@ -135,7 +134,7 @@ defineExpose({ focus: () => inputEl.value?.focus() })
&__icon { &__icon {
position: absolute; position: absolute;
inset-inline-start: 0.75rem; inset-inline-end: 0.75rem;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
display: flex; display: flex;
-1
View File
@@ -143,7 +143,6 @@ onMounted(() => {
&:focus { &:focus {
border-color: var(--color-thd-gray); border-color: var(--color-thd-gray);
box-shadow: 0 0 0 1px var(--color-validate);
} }
&--no-resize { &--no-resize {
+1 -1
View File
@@ -1,6 +1,7 @@
<template> <template>
<div class="toggle-switch"> <div class="toggle-switch">
<label class="toggle-switch__label"> <label class="toggle-switch__label">
<span class="toggle-switch__text">{{ label }}</span>
<button <button
type="button" type="button"
:disabled="disabled" :disabled="disabled"
@@ -15,7 +16,6 @@
}" }"
/> />
</button> </button>
<span class="toggle-switch__text">{{ label }}</span>
</label> </label>
<div v-if="error" class="toggle-switch__error dir-rtl" :class="{ 'animate-shake': vibration }"> <div v-if="error" class="toggle-switch__error dir-rtl" :class="{ 'animate-shake': vibration }">
{{ error }} {{ error }}
+562
View File
@@ -0,0 +1,562 @@
<template>
<div class="voice-recorder">
<div class="voice-recorder__stage">
<button
v-if="!isRecording && !audioUrl"
type="button"
class="voice-recorder__main-btn"
:disabled="disabled"
@click="startRecording"
>
<SvgIcon name="microphone" :size="48" color="transparent" />
</button>
<button
v-else-if="isRecording"
type="button"
class="voice-recorder__main-btn voice-recorder__main-btn--recording"
@click="stopRecording"
>
<SvgIcon name="close" :size="40" color="var(--color-error)" />
</button>
<div v-else class="voice-recorder__player">
<button type="button" class="voice-recorder__play-btn" @click="togglePlay">
<SvgIcon :name="isPlaying ? 'pause' : 'play'" :size="24" color="#848484" />
</button>
<div
ref="trackEl"
class="voice-recorder__track"
@pointerdown="onSeekStart"
@pointermove="onSeekMove"
@pointerup="onSeekEnd"
@pointercancel="onSeekEnd"
>
<div class="voice-recorder__track-fill" :style="{ width: `${progress}%` }" />
<span class="voice-recorder__knob" :style="{ left: `${progress}%` }" />
</div>
<span class="voice-recorder__time">{{ timeLabel }}</span>
<audio
ref="audioEl"
:src="audioUrl"
class="voice-recorder__audio"
@ended="onEnded"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoadedMetadata"
/>
</div>
<p v-if="isRecording" class="voice-recorder__status">
<span class="voice-recorder__dot" />
در حال ضبط...
<span class="voice-recorder__timer">{{ formattedTime }}</span>
</p>
</div>
<div class="voice-recorder__actions">
<button
v-if="audioUrl && !isRecording && !disabled"
type="button"
class="voice-recorder__btn"
:title="'ضبط مجدد'"
@click="resetAndStart"
>
<SvgIcon name="microphone" :size="20" color="transparent" />
</button>
<button
type="button"
class="voice-recorder__btn"
:disabled="isRecording || disabled"
:title="'بارگذاری فایل'"
@click="triggerFilePicker"
v-if="canUpload"
>
<SvgIcon name="upload" :size="20" color="var(--color-thd-gray)" />
</button>
<input
ref="fileInput"
type="file"
accept="audio/*"
class="voice-recorder__file-input"
@change="onFileSelected"
/>
</div>
<p v-if="error" class="voice-recorder__error">{{ error }}</p>
</div>
<PermissionModal v-if="isModal('VoiceRecorderPermissionModal')" />
</template>
<script setup>
import { toast } from 'vue3-toastify'
import useModal from '@/composables/useModal'
import { PermissionModal } from '@/features/auth'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps({
modelValue: { type: [Object, File, String, null], default: null },
maxSeconds: { type: Number, default: 600 },
maxSizeKb: { type: Number, default: 25_600 },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
canUpload: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'recorded'])
const { openModal, isModal } = useModal()
const audioUrl = ref(
typeof props.modelValue === 'string' ? props.modelValue : props.modelValue?.url || null
)
const audioFile = ref(null)
const isRecording = ref(false)
const isPlaying = ref(false)
const elapsedMs = ref(0)
const progress = ref(0)
const duration = ref(0)
const currentSeconds = ref(0)
const audioEl = ref(null)
const trackEl = ref(null)
const fileInput = ref(null)
let seeking = false
let mediaRecorder = null
let mediaStream = null
let chunks = []
let timerHandle = null
let timerStart = 0
let recorderType = null
// The backend only accepts these audio extensions. Browsers can't record mp3/wav,
// but most can record into an mp4 container (→ .m4a), so prefer a supported type
// whose extension the backend allows; fall back to webm only as a last resort.
const ACCEPTED_AUDIO_TYPES = [
{ mime: 'audio/mp4', ext: 'm4a' },
{ mime: 'audio/mpeg', ext: 'mp3' },
{ mime: 'audio/wav', ext: 'wav' },
]
const pickRecorderType = () => {
const supported = ACCEPTED_AUDIO_TYPES.find((c) =>
window.MediaRecorder?.isTypeSupported?.(c.mime)
)
return supported || { mime: 'audio/webm', ext: 'webm' }
}
const formattedTime = computed(() => {
const s = Math.floor(elapsedMs.value / 1000)
const mm = String(Math.floor(s / 60)).padStart(2, '0')
const ss = String(s % 60).padStart(2, '0')
return `${mm}:${ss}`
})
const stopTracks = () => {
if (mediaStream) {
mediaStream.getTracks().forEach((t) => t.stop())
mediaStream = null
}
}
const clearTimer = () => {
if (timerHandle) {
clearInterval(timerHandle)
timerHandle = null
}
}
const revokePreview = () => {
if (audioUrl.value && audioUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(audioUrl.value)
}
}
const setRecording = (file) => {
revokePreview()
audioFile.value = file
audioUrl.value = URL.createObjectURL(file)
progress.value = 0
duration.value = 0
currentSeconds.value = 0
isPlaying.value = false
emit('update:modelValue', file)
emit('recorded', file)
}
const startRecording = async () => {
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
chunks = []
recorderType = pickRecorderType()
mediaRecorder = new window.MediaRecorder(mediaStream, { mimeType: recorderType.mime })
mediaRecorder.ondataavailable = (e) => {
if (e.data?.size > 0) chunks.push(e.data)
}
mediaRecorder.onstop = () => {
isRecording.value = false
stopTracks()
const mime = recorderType?.mime || mediaRecorder?.mimeType || 'audio/webm'
const ext = recorderType?.ext || 'webm'
const blob = new Blob(chunks, { type: mime })
const file = new File([blob], `recording-${Date.now()}.${ext}`, {
type: mime,
lastModified: Date.now(),
})
setRecording(file)
}
mediaRecorder.start()
isRecording.value = true
elapsedMs.value = 0
timerStart = Date.now()
timerHandle = setInterval(() => {
elapsedMs.value = Date.now() - timerStart
if (elapsedMs.value >= props.maxSeconds * 1000) stopRecording()
}, 250)
} catch {
openModal('VoiceRecorderPermissionModal', { kind: 'audio' })
}
}
const stopRecording = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
clearTimer()
}
const resetAndStart = () => {
if (isPlaying.value) {
audioEl.value?.pause()
isPlaying.value = false
}
revokePreview()
audioFile.value = null
audioUrl.value = null
progress.value = 0
emit('update:modelValue', null)
startRecording()
}
const triggerFilePicker = () => fileInput.value?.click()
const onFileSelected = (event) => {
const file = event.target?.files?.[0]
if (event.target) event.target.value = ''
if (!file) return
if (file.size > props.maxSizeKb * 1024) {
toast.error(`حجم فایل صوتی نباید بیشتر از ${props.maxSizeKb} کیلوبایت باشد.`)
return
}
setRecording(file)
}
const togglePlay = () => {
if (!audioEl.value) return
if (audioEl.value.paused) {
audioEl.value.play().then(() => {
isPlaying.value = true
})
} else {
audioEl.value.pause()
isPlaying.value = false
}
}
const onEnded = () => {
isPlaying.value = false
progress.value = 0
currentSeconds.value = 0
}
const onTimeUpdate = () => {
const a = audioEl.value
if (!a || !Number.isFinite(duration.value) || duration.value <= 0) return
currentSeconds.value = a.currentTime
progress.value = (a.currentTime / duration.value) * 100
}
// MediaRecorder blobs report `Infinity` duration in Chromium until the element
// is seeked past the end once — force that, then rewind.
const onLoadedMetadata = () => {
const a = audioEl.value
if (!a) return
if (Number.isFinite(a.duration)) {
duration.value = a.duration
return
}
const restore = () => {
if (!Number.isFinite(a.duration)) return
duration.value = a.duration
a.currentTime = 0
a.removeEventListener('timeupdate', restore)
}
a.addEventListener('timeupdate', restore)
a.currentTime = 1e10
}
const formatClock = (secs) => {
if (!Number.isFinite(secs) || secs < 0) return '00:00'
const total = Math.floor(secs)
const h = Math.floor(total / 3600)
const mm = String(Math.floor((total % 3600) / 60)).padStart(2, '0')
const ss = String(total % 60).padStart(2, '0')
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
}
const timeLabel = computed(() =>
formatClock(isPlaying.value || currentSeconds.value > 0 ? currentSeconds.value : duration.value)
)
const seekTo = (clientX) => {
const a = audioEl.value
const track = trackEl.value
if (!a || !track || !Number.isFinite(duration.value) || duration.value <= 0) return
const rect = track.getBoundingClientRect()
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width))
a.currentTime = ratio * duration.value
currentSeconds.value = a.currentTime
progress.value = ratio * 100
}
const onSeekStart = (event) => {
seeking = true
trackEl.value?.setPointerCapture?.(event.pointerId)
seekTo(event.clientX)
}
const onSeekMove = (event) => {
if (seeking) seekTo(event.clientX)
}
const onSeekEnd = () => {
seeking = false
}
watch(
() => props.modelValue,
(val) => {
if (val == null) {
revokePreview()
audioFile.value = null
audioUrl.value = null
progress.value = 0
}
}
)
onBeforeUnmount(() => {
stopRecording()
stopTracks()
clearTimer()
revokePreview()
})
</script>
<style lang="scss" scoped>
.voice-recorder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: #f3f4f6;
border-radius: 1.5rem;
min-height: 18rem;
justify-content: space-between;
height: 100%;
&__stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.625rem;
width: 100%;
height: 100%;
}
&__main-btn {
width: 8rem;
height: 8rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover:enabled {
transform: scale(1.02);
box-shadow: 0 4px 14px rgba(0, 0, 0, 8%);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
&--recording {
box-shadow: 0 0 0 6px rgba(204, 40, 49, 12%);
animation: pulse 1.4s infinite;
}
}
&__player {
direction: ltr;
display: flex;
align-items: center;
gap: 1.25rem;
width: 100%;
padding: 1.25rem 1.5rem;
background: #fff;
border-radius: 1rem;
}
&__play-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s ease;
&:hover {
transform: scale(1.08);
}
}
&__track {
position: relative;
flex: 1;
height: 2px;
border-radius: 9999px;
background: #e8e8e8;
cursor: pointer;
touch-action: none;
&::before {
content: '';
position: absolute;
inset: -0.625rem 0;
}
}
&__track-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: 9999px;
background: #5d5d5d;
}
&__knob {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 0.625rem;
height: 0.625rem;
border-radius: 9999px;
background: #4b4b4b;
pointer-events: none;
}
&__time {
font-family: var(--font-family-en);
font-size: 0.75rem;
color: #b5b5b5;
flex-shrink: 0;
min-width: 3rem;
text-align: right;
}
&__audio {
display: none;
}
&__status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: var(--color-error);
margin: 0;
}
&__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: var(--color-error);
}
&__timer {
margin-inline-start: 0.5rem;
font-family: var(--font-family-en);
color: #5d5d5d;
}
&__actions {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
&__btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
background: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 6%);
transition: transform 0.15s ease;
&:hover:enabled {
transform: scale(1.05);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
&__file-input {
display: none;
}
&__error {
margin: 0;
font-family: var(--font-family-fa);
font-size: 0.75rem;
color: var(--color-error);
}
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 6px rgba(204, 40, 49, 12%);
}
50% {
box-shadow: 0 0 0 10px rgba(204, 40, 49, 18%);
}
}
</style>
+6 -2
View File
@@ -14,17 +14,22 @@ import { computed } from 'vue'
import { iconRegistry } from '@/components/icons/registry' import { iconRegistry } from '@/components/icons/registry'
/** @typedef {import('@/components/icons/icon-names').IconName} IconName */
const props = defineProps({ const props = defineProps({
/** @type {import('vue').PropType<IconName>} */
name: { type: String, required: true }, name: { type: String, required: true },
size: { type: [String, Number], default: '1.25rem' }, size: { type: [String, Number], default: '1.25rem' },
color: { type: String, default: 'currentColor' }, color: { type: String, default: 'transparent' },
}) })
const component = computed(() => { const component = computed(() => {
const c = iconRegistry[props.name] const c = iconRegistry[props.name]
if (!c && import.meta.env.DEV) { if (!c && import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.warn(`[SvgIcon] unknown icon "${props.name}"`) console.warn(`[SvgIcon] unknown icon "${props.name}"`)
} }
return c return c
}) })
@@ -42,6 +47,5 @@ const iconStyle = computed(() => ({
.svg-icon { .svg-icon {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
fill: currentcolor;
} }
</style> </style>
+66
View File
@@ -0,0 +1,66 @@
// AUTO-GENERATED — do not edit by hand.
// Regenerated whenever src/assets/icons/*.svg changes (via scripts/vite-icon-names.js).
export type IconName =
| 'arrow-left'
| 'arrows-clockwise'
| 'attach-file'
| 'bell'
| 'book'
| 'calendar'
| 'card-corner-arrow'
| 'caret-down'
| 'caret-left'
| 'caret-right'
| 'certificate'
| 'chat'
| 'chat-centered-dots'
| 'chat-teardrop-text'
| 'check'
| 'check-circle'
| 'check-square'
| 'clock'
| 'close'
| 'copy'
| 'envelope-open'
| 'eye'
| 'eye-slash'
| 'file'
| 'folder-simple-star'
| 'funnel'
| 'graduation-cap'
| 'heart'
| 'instagram'
| 'link'
| 'list-bullets'
| 'map-pin-simple-area'
| 'menu'
| 'microphone'
| 'monitor'
| 'mood'
| 'newspaper'
| 'paper-plane'
| 'paper-plane-right'
| 'pause'
| 'pencil'
| 'phone'
| 'play'
| 'plus'
| 'power'
| 'scroll'
| 'shopping-bag'
| 'smiley-sad'
| 'spinner'
| 'square'
| 'telegram'
| 'trash'
| 'upload'
| 'user'
| 'user-circle-check'
| 'user-sound'
| 'users'
| 'users-three'
| 'video'
| 'warning'
export const iconNames: readonly IconName[]
+3 -4
View File
@@ -1,7 +1,6 @@
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { tokenService } from '@/services/api/token-service'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { tokenService } from '@/services/api/token-service'
export default function useAuth() { export default function useAuth() {
const store = useAuthStore() const store = useAuthStore()
@@ -16,8 +15,8 @@ export default function useAuth() {
const savePhoneNumber = (phoneNumber) => { const savePhoneNumber = (phoneNumber) => {
if (!phoneNumber) return if (!phoneNumber) return
const current = tokenService.getUserInfo() || {} const current = tokenService.getUserInfo() || {}
if (!current.phoneNumber) { if (!current.phone && !current.phoneNumber) {
store.setUser({ ...current, phoneNumber }) store.setUser({ ...current, phone: phoneNumber })
} }
} }
+50
View File
@@ -0,0 +1,50 @@
import { toFaDigits } from '@/utils/persian'
import { toJalaali } from '@/utils/date-utils'
const WEEKDAY_NAMES = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']
const MONTH_NAMES = [
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
]
const GREGORIAN_WEEKDAYS = ['Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri']
const DAY_MS = 24 * 60 * 60 * 1000
/**
* Builds the current Jalaali week (Saturday → Friday) anchored to the Asia/Tehran
* timezone, independent of the viewer's local clock. The day matching Tehran's
* "today" is flagged `selected`. Returns `{ month, days }` for DashboardCalendar.
*/
export const useTehranCalendar = () => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: 'numeric',
day: 'numeric',
weekday: 'short',
}).formatToParts(new Date())
const part = (type) => parts.find((p) => p.type === type)?.value
const todayIndex = GREGORIAN_WEEKDAYS.indexOf(part('weekday'))
const todayUtc = Date.UTC(Number(part('year')), Number(part('month')) - 1, Number(part('day')))
const weekStartUtc = todayUtc - todayIndex * DAY_MS
const today = new Date(todayUtc)
const { jm } = toJalaali(today.getUTCFullYear(), today.getUTCMonth() + 1, today.getUTCDate())
const days = WEEKDAY_NAMES.map((weekday, i) => {
const date = new Date(weekStartUtc + i * DAY_MS)
const { jd } = toJalaali(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate())
return { weekday, day: toFaDigits(jd), selected: i === todayIndex }
})
return { month: `${MONTH_NAMES[jm - 1]} ماه`, days }
}
+9 -3
View File
@@ -1,8 +1,14 @@
import { ref } from 'vue' import { ref } from 'vue'
export default function useYup(validationSchema) { export default function useYup(validationSchema, getValues) {
const errors = ref({}) const errors = ref({})
const readFormValues = () => {
if (typeof getValues !== 'function') return {}
const v = getValues()
return v && typeof v === 'object' ? v : {}
}
const validate = async (values, options) => { const validate = async (values, options) => {
const mergeOptions = { abortEarly: false, stripUnknown: true, ...options } const mergeOptions = { abortEarly: false, stripUnknown: true, ...options }
try { try {
@@ -30,7 +36,7 @@ export default function useYup(validationSchema) {
const pathParts = field.split('.') const pathParts = field.split('.')
const lastPart = pathParts.pop() const lastPart = pathParts.pop()
let schemaContext = validationSchema let schemaContext = validationSchema
let context = {} let context = { ...readFormValues() }
if (pathParts.length > 0) { if (pathParts.length > 0) {
pathParts.forEach((part) => { pathParts.forEach((part) => {
if (part.includes('[')) { if (part.includes('[')) {
@@ -47,7 +53,7 @@ export default function useYup(validationSchema) {
await schemaContext.validateAt(lastPart, context) await schemaContext.validateAt(lastPart, context)
errors.value[field] = null errors.value[field] = null
} else { } else {
await validationSchema.validateAt(field, { [field]: value }) await validationSchema.validateAt(field, { ...context, [field]: value })
errors.value[field] = null errors.value[field] = null
} }
} catch (error) { } catch (error) {
+42 -7
View File
@@ -1,7 +1,7 @@
export const fields = { export const fields = {
mobile: 'شماره موبایل', mobile: 'شماره موبایل',
phoneNumber: 'شماره موبایل', phoneNumber: 'شماره موبایل',
phone: 'تلفن ثابت', phone: 'شماره موبایل',
password: 'رمز عبور', password: 'رمز عبور',
passwordConfirmation: 'تکرار رمز عبور', passwordConfirmation: 'تکرار رمز عبور',
newPassword: 'رمز عبور جدید', newPassword: 'رمز عبور جدید',
@@ -15,8 +15,14 @@ export const fields = {
lastName: 'نام خانوادگی', lastName: 'نام خانوادگی',
nationalCode: 'کد ملی', nationalCode: 'کد ملی',
postalCode: 'کد پستی', postalCode: 'کد پستی',
virtualPhoneNumber: 'شماره تلفن مجازی',
seminaryCode: 'کد حوزوی',
seminaryServiceCenterCode: 'کد مرکز خدمات حوزه',
childrenCount: 'تعداد فرزند',
marriageStatus: 'وضعیت تأهل',
birthDate: 'تاریخ تولد', birthDate: 'تاریخ تولد',
birthday: 'تاریخ تولد',
gender: 'جنسیت', gender: 'جنسیت',
maritalStatus: 'وضعیت تأهل', maritalStatus: 'وضعیت تأهل',
@@ -27,33 +33,41 @@ export const fields = {
address: 'آدرس', address: 'آدرس',
title: 'عنوان', title: 'عنوان',
subject: 'موضوع',
category: 'نوع خدمت',
note: 'یادداشت',
description: 'توضیحات', description: 'توضیحات',
bio: 'بیوگرافی', bio: 'بیوگرافی',
message: 'پیام', message: 'پیام',
content: 'محتوا', content: 'محتوا',
rejectionReason: 'دلیل رد', rejectionReason: 'دلیل رد',
reason: 'دلیل رد',
adminNote: 'یادداشت مدیر', adminNote: 'یادداشت مدیر',
reviewerNote: 'یادداشت ارزیاب',
activitySummary: 'خلاصهٔ فعالیت', activitySummary: 'خلاصهٔ فعالیت',
avatar: 'تصویر پروفایل', avatar: 'تصویر پروفایل',
avatarId: 'تصویر پروفایل',
avatarMediaId: 'تصویر پروفایل',
image: 'تصویر', image: 'تصویر',
status: 'وضعیت', status: 'وضعیت',
role: 'نقش', role: 'نقش',
roleId: 'نقش', roleId: 'نقش',
isActive: 'فعال', isActive: 'فعال',
isActiveByDefault: 'فعال به‌صورت پیش‌فرض',
randomize: 'تصادفی', randomize: 'تصادفی',
startDate: 'تاریخ شروع', startDate: 'تاریخ شروع',
endDate: 'تاریخ پایان', endDate: 'تاریخ پایان',
deadline: 'مهلت تحویل',
fromDate: 'از تاریخ', fromDate: 'از تاریخ',
toDate: 'تا تاریخ', toDate: 'تا تاریخ',
capacity: 'ظرفیت', capacity: 'ظرفیت',
minCapacity: 'حداقل ظرفیت', minCapacity: 'حداقل ظرفیت',
maxCapacity: 'حداکثر ظرفیت', maxCapacity: 'حداکثر ظرفیت',
defaultCapacity: 'ظرفیت پیش‌فرض', score: 'نمره',
minimumScore: 'حد نصاب قبولی',
order: 'ترتیب', order: 'ترتیب',
priority: 'اولویت', priority: 'اولویت',
@@ -64,30 +78,51 @@ export const fields = {
minAssignments: 'حداقل تکالیف', minAssignments: 'حداقل تکالیف',
termId: 'ترم', termId: 'ترم',
templateId: 'قالب',
courseId: 'دوره', courseId: 'دوره',
courseTemplateId: 'قالب دوره',
sessionId: 'جلسه', sessionId: 'جلسه',
missionaryRequestId: 'درخواست مبلغ',
sessionType: 'نوع جلسه', sessionType: 'نوع جلسه',
type: 'نوع جلسه',
link: 'لینک جلسه',
teacherId: 'مدرس', teacherId: 'مدرس',
defaultTeacherId: 'مدرس پیش‌فرض',
studentIds: 'دانش‌آموزان', studentIds: 'دانش‌آموزان',
prerequisiteCourseIds: 'دوره‌های پیش‌نیاز',
contentMediaId: 'فایل محتوای دوره',
coverMediaId: 'تصویر جلد',
imageId: 'تصویر',
educationStatus: 'وضعیت تحصیلی', educationStatus: 'وضعیت تحصیلی',
fieldOfStudy: 'رشتهٔ تحصیلی', fieldOfStudy: 'رشتهٔ تحصیلی',
seminaryLevel: 'مقطع حوزه', seminaryLevel: 'مقطع حوزه',
universityLevel: 'مقطع دانشگاهی',
universityName: 'نام حوزه علمیه / دانشگاه',
degree: 'مقطع تحصیلی',
jobTitle: 'عنوان شغل',
organizationName: 'نام سازمان',
hijabApproach: 'رویکرد حجاب', hijabApproach: 'رویکرد حجاب',
prerequisites: 'پیش‌نیازها', prerequisites: 'پیش‌نیازها',
specializedTopics: 'موضوعات تخصصی', specializedTopics: 'موضوعات تخصصی',
propagationPlatforms: 'بسترهای تبلیغ', propagationPlatforms: 'بسترهای تبلیغ',
propagationMethodDescription: 'شرح روش تبلیغ', propagationMethodDescription: 'شرح روش تبلیغ',
propagationExperienceYears: نوات تجربهٔ تبلیغ', propagationExperienceYears: ابقه فعالیت تبلیغی',
relevantCertificates: 'گواهی‌نامه‌های مرتبط', relevantCertificates: 'گواهی‌نامه‌های مرتبط',
onlinePlatformDetails: 'جزئیات بستر آنلاین', onlinePlatformDetails: 'جزئیات بستر آنلاین',
otherPlatformDetails: 'جزئیات سایر بسترها', otherPlatformDetails: 'جزئیات سایر بسترها',
faithProductionAudio: 'صدای تولید ایمانی', faithProductionAudio: 'صدای تولید ایمانی',
faithProductionId: 'صدای تولید ایمانی',
leaderMessageVideo: 'ویدیوی پیام رهبر', leaderMessageVideo: 'ویدیوی پیام رهبر',
leaderMessageId: 'ویدیوی پیام رهبر',
responseToLowSatisfaction: 'واکنش به رضایت پایین مخاطب',
responseToLowSatisfactionDescription: 'توضیح واکنش به رضایت پایین مخاطب',
responseToSessionCancellation: 'واکنش به لغو جلسه',
responseToSessionCancellationDescription: 'توضیح واکنش به لغو جلسه',
responseToAudienceConflict: 'واکنش به تعارض با مخاطب',
responseToAudienceConflictDescription: 'توضیح واکنش به تعارض با مخاطب',
responseToCompetingPropagator: 'واکنش به مبلغ رقیب',
responseToCompetingPropagatorDescription: 'توضیح واکنش به مبلغ رقیب',
responseToUnqualifiedAdvisors: 'واکنش به مشاوران غیرمتخصص',
} }
+1 -3
View File
@@ -1,7 +1,5 @@
import { string } from 'yup' import { string } from 'yup'
import { fields } from '@/constants/fields'
export const phoneNumberRule = string() export const phoneNumberRule = string()
.required() .required()
.matches(/^09\d{9}$/, ({ path }) => `${fields[path] || path} نامعتبر است`) .matches(/^09\d{9}$/, 'شماره موبایل باید با ۰۹ شروع شود و ۱۱ رقم باشد')
+59 -32
View File
@@ -5,8 +5,11 @@ export const getEnumObject = (enumArray, key = 'value', value = 'label') =>
}, {}) }, {})
export const ROLES = Object.freeze([ export const ROLES = Object.freeze([
{ value: 'admin', label: 'مدیر' }, { value: 'admin', label: 'مدیر', changeable: false },
{ value: 'student', label: 'دانش‌آموز' }, { value: 'student', label: 'طلبه', changeable: true },
{ value: 'teacher', label: 'استاد', changeable: true },
{ value: 'counselor', label: 'مشاور', changeable: true },
{ value: 'missionary', label: 'مبلغ', changeable: true },
]) ])
export const LOGIN_STEPS = Object.freeze({ export const LOGIN_STEPS = Object.freeze({
@@ -35,18 +38,21 @@ export const STUDENT_REGISTRATION = Object.freeze({
export const STUDENT_REGISTRATION_ORDER = Object.freeze(Object.values(STUDENT_REGISTRATION)) export const STUDENT_REGISTRATION_ORDER = Object.freeze(Object.values(STUDENT_REGISTRATION))
// User lifecycle states (Set User Status endpoint):
// pending registered, phone not yet OTP-verified
// verified phone OTP-verified and registration steps completed (awaiting admin)
// approved approved by admin (full access)
// blocked blocked by admin (no access; existing tokens revoked)
export const USER_STATUS = Object.freeze({ export const USER_STATUS = Object.freeze({
pending: 'تأیید نشده',
verified: 'در انتظار تأیید',
approved: 'تأیید شده', approved: 'تأیید شده',
pending: 'در انتظار',
rejected: 'رد شده',
blocked: 'مسدود', blocked: 'مسدود',
}) })
export const MARITAL_STATUS = Object.freeze({ export const MARITAL_STATUS = Object.freeze({
single: 'مجرد', single: 'مجرد',
married: 'متاهل', married: 'متاهل',
divorced: 'مطلقه',
widowed: 'بیوه',
}) })
export const GENDER = Object.freeze({ export const GENDER = Object.freeze({
@@ -54,19 +60,12 @@ export const GENDER = Object.freeze({
female: 'خانم', female: 'خانم',
}) })
export const ROLE_LABELS = Object.freeze({ export const ROLE_LABELS = Object.freeze(Object.fromEntries(ROLES.map((r) => [r.value, r.label])))
admin: 'مدیر',
counselor: 'مشاور',
missionary: 'مبلغ',
student: 'دانشجو',
teacher: 'استاد',
})
export const CHANGEABLE_ROLES = Object.freeze(['student', 'teacher', 'missionary', 'counselor']) export const CHANGEABLE_ROLES = Object.freeze(ROLES.filter((r) => r.changeable).map((r) => r.value))
export const EDUCATION_STATUS = Object.freeze({ export const EDUCATION_STATUS = Object.freeze({
seminary_student: 'طلبه', seminary_student: 'طلبه',
seminary_and_university_student: 'طلبه و دانشجو',
graduate: 'فارغ التحصیلی', graduate: 'فارغ التحصیلی',
}) })
@@ -74,14 +73,13 @@ export const SEMINARY_LEVEL = Object.freeze({
level_2: 'سطح دو', level_2: 'سطح دو',
level_3: 'سطح سه', level_3: 'سطح سه',
level_4: 'سطح چهار', level_4: 'سطح چهار',
not_applicable: 'طلبه نیستم',
}) })
export const UNIVERSITY_LEVEL = Object.freeze({ export const UNIVERSITY_LEVEL = Object.freeze({
bachelor: ' مقطع کارشناسی (لیسانس)', bachelor: ' مقطع کارشناسی (لیسانس)',
master: ' مقطع کارشناسی ارشد (فوق لیسانس)', master: ' مقطع کارشناسی ارشد (فوق لیسانس)',
phd: ' مقطع دکتری', phd: ' مقطع دکتری',
not_applicable: 'دانشجو نیستم', not_applicable: 'طلبه نیستم',
}) })
export const PROPAGATION_PLATFORM = Object.freeze({ export const PROPAGATION_PLATFORM = Object.freeze({
@@ -133,9 +131,22 @@ export const VERIFICATION_MEDIA_TYPE = Object.freeze({
LEADER_MESSAGE: 'leader_message', LEADER_MESSAGE: 'leader_message',
}) })
export const COURSE_CONTENT_TYPE = Object.freeze({
video: 'ویدئو',
voice: 'صوت',
text: 'متن',
})
export const COURSE_CONTENT_TYPE_ACCEPT = Object.freeze({
video: 'video/*',
voice: 'audio/*',
text: '.pdf,.doc,.docx,.txt',
})
export const SESSION_TYPE = Object.freeze({ export const SESSION_TYPE = Object.freeze({
in_person: 'حضوری', in_person: 'حضوری',
online: 'آنلاین', online: 'آنلاین',
offline: 'آفلاین',
video: 'ویدئو', video: 'ویدئو',
audio: 'صوتی', audio: 'صوتی',
text: 'متن', text: 'متن',
@@ -155,17 +166,28 @@ export const ASSIGNMENT_PRIORITY = Object.freeze({
optional: 'اختیاری', optional: 'اختیاری',
}) })
// Single ticket-lifecycle vocabulary. Used by every ticket-like resource —
// admin /messages, admin /services, admin /consultations, counselor, student
// /my-tickets, /user/services, /user/consultants. Backend exposes exactly
// these three values across all of them.
export const TICKET_STATUS = Object.freeze({ export const TICKET_STATUS = Object.freeze({
pending: 'در انتظار پاسخ', open: 'باز',
answered: 'پاسخ داده شده', answered: 'پاسخ داده شده',
closed: 'بسته شده', closed: 'بسته شده',
}) })
// Priority levels for student-created tickets. Sent to the backend on POST
// /student/tickets (currently carried via the `category` field).
export const TICKET_PRIORITY = Object.freeze({
low: 'کم اهمیت',
normal: 'عادی',
high: 'مهم',
urgent: 'فوری',
})
export const STUDENT_COURSE_STATUS = Object.freeze({ export const STUDENT_COURSE_STATUS = Object.freeze({
watching: 'در حال گذراندن', active: 'در حال گذراندن',
waitForExam: 'در انتظار آزمون',
completed: 'تکمیل شده', completed: 'تکمیل شده',
failed: 'عدم قبولی',
}) })
export const PRIORITY_STATUS = Object.freeze({ export const PRIORITY_STATUS = Object.freeze({
@@ -174,13 +196,6 @@ export const PRIORITY_STATUS = Object.freeze({
low: 'پایین', low: 'پایین',
}) })
export const SERVICE_STATUS = Object.freeze({
approved: 'تایید شده',
pending: 'در انتظار بررسی',
in_progress: 'در حال انجام',
completed: 'تکمیل شده',
})
export const SERVICE_TYPE = Object.freeze({ export const SERVICE_TYPE = Object.freeze({
loan: 'وام', loan: 'وام',
consultation: 'مشاوره', consultation: 'مشاوره',
@@ -188,8 +203,20 @@ export const SERVICE_TYPE = Object.freeze({
other: 'سایر', other: 'سایر',
}) })
export const CONSULTATION_STATUS = Object.freeze({ // Lifecycle of a dispatch request assigned to a missionary. `pending` is the
in_progress: 'در حال گفتگو', // initial backend state; the missionary may set seen, accepted, or rejected.
answered: 'پاسخ داده شده', export const MISSIONARY_REQUEST_STATUS = Object.freeze({
closed: 'بسته شده', pending: 'در انتظار بررسی',
seen: 'دیده شده',
accepted: 'پذیرفته شده',
rejected: 'رد شده',
})
// Landing route per role after login / when a logged-in user hits a guest
// page. Roles not listed here fall back to the student dashboard.
export const ROLE_HOME_ROUTES = Object.freeze({
admin: 'admin-dashboard',
counselor: 'counselor-dashboard',
missionary: 'missionary-dashboard',
student: 'student-dashboard',
}) })
@@ -5,19 +5,19 @@
<div class="assignment-item__sub"> <div class="assignment-item__sub">
<span class="assignment-item__sub-label">دوره:</span> <span class="assignment-item__sub-label">دوره:</span>
<span class="assignment-item__sub-value"> <span class="assignment-item__sub-value">
{{ assignment.courseTemplate?.title || assignment.courseTemplateTitle || '—' }} {{ assignment.session?.course?.title || '—' }}
</span> </span>
<span class="assignment-item__dot">|</span> <span class="assignment-item__dot">|</span>
<span class="assignment-item__sub-label">جلسه:</span> <span class="assignment-item__sub-label">جلسه:</span>
<span class="assignment-item__sub-value"> <span class="assignment-item__sub-value">
{{ assignment.session?.title || assignment.sessionTitle || '—' }} {{ assignment.session?.title || '—' }}
</span> </span>
</div> </div>
</div> </div>
<div class="assignment-item__meta"> <div class="assignment-item__meta">
<div class="assignment-item__pill"> <div class="assignment-item__pill">
<span class="assignment-item__pill-label">مدت زمان ارسال:</span> <span class="assignment-item__pill-label">تاریخ سررسید:</span>
<span class="assignment-item__pill-value">{{ duration }}</span> <span class="assignment-item__pill-value">{{ duration }}</span>
</div> </div>
<div class="assignment-item__pill"> <div class="assignment-item__pill">
@@ -76,11 +76,10 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import CircleButton from '@/components/CircleButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import { formatJalaaliDate } from '@/utils/date-utils' import { formatJalaaliDate } from '@/utils/date-utils'
import CircleButton from '@/components/CircleButton.vue'
const props = defineProps({ const props = defineProps({
assignment: { type: Object, required: true }, assignment: { type: Object, required: true },
@@ -88,17 +87,13 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete', 'show-details', 'show-submissions']) const emit = defineEmits(['edit', 'delete', 'show-details', 'show-submissions'])
const duration = computed(() => const duration = computed(() => formatJalaaliDate(props.assignment.deadline) || '—')
props.assignment.durationDays == null ? '—' : `${props.assignment.durationDays} روز`
)
const submissions = computed(() => const submissions = computed(() =>
props.assignment.submissionsCount == null ? '—' : `${props.assignment.submissionsCount} نفر` props.assignment.submittersCount == null ? '—' : `${props.assignment.submittersCount} نفر`
) )
const createdAt = computed( const createdAt = computed(() => formatJalaaliDate(props.assignment.createdAt) || '—')
() => props.assignment.faCreatedAt || formatJalaaliDate(props.assignment.createdAt) || '—'
)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -3,20 +3,34 @@
<div class="assignments-filters__grid"> <div class="assignments-filters__grid">
<TextField v-model="form.title" name="title" label="عنوان تکلیف"> <TextField v-model="form.title" name="title" label="عنوان تکلیف">
<template #appendIcon> <template #appendIcon>
<SvgIcon name="book" :size="20" color="var(--color-thd-gray)" /> <SvgIcon name="monitor" :size="20" color="var(--color-thd-gray)" />
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
v-model="form.courseTemplateId" v-model="form.termId"
name="courseTemplateId" name="termId"
label="دوره الگو" label="ترم"
:options="termOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchTerms"
@update:modelValue="onTermChange"
/>
<SelectField
v-if="form.termId"
v-model="form.courseId"
name="courseId"
label="دوره"
:options="templateOptions" :options="templateOptions"
option-label="title" option-label="title"
option-value="id" option-value="id"
:searchable="true" :searchable="true"
:on-search="searchTemplates" :on-search="searchTemplates"
@update:modelValue="onCourseChange"
/> />
<SelectField <SelectField
v-if="form.courseId"
v-model="form.sessionId" v-model="form.sessionId"
name="sessionId" name="sessionId"
label="جلسه" label="جلسه"
@@ -40,7 +54,7 @@
@click="onReset" @click="onReset"
> >
<template #icon> <template #icon>
<SvgIcon name="close" :size="20" /> <SvgIcon name="close" color="black" :size="20" />
</template> </template>
</CircleButton> </CircleButton>
<CircleButton <CircleButton
@@ -59,22 +73,23 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import useDebounce from '@/composables/useDebounce'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue' import TextField from '@/components/form/TextField.vue'
import CircleButton from '@/components/CircleButton.vue'
import SelectField from '@/components/form/SelectField.vue' import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue' import DatePickerField from '@/components/form/DatePickerField.vue'
import CircleButton from '@/components/CircleButton.vue' import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import SvgIcon from '@/components/icons/SvgIcon.vue' import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions' import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import useDebounce from '@/composables/useDebounce'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object, type: Object,
default: () => ({ default: () => ({
title: '', title: '',
courseTemplateId: '', termId: '',
courseId: '',
sessionId: '', sessionId: '',
fromDate: '', fromDate: '',
toDate: '', toDate: '',
@@ -86,7 +101,8 @@ const emit = defineEmits(['update:modelValue', 'apply', 'reset'])
const emptyForm = () => ({ const emptyForm = () => ({
title: '', title: '',
courseTemplateId: '', termId: '',
courseId: '',
sessionId: '', sessionId: '',
fromDate: '', fromDate: '',
toDate: '', toDate: '',
@@ -103,24 +119,41 @@ watch(
const todayIso = new Date().toISOString() const todayIso = new Date().toISOString()
const termSearch = ref('')
const termFilters = computed(() => ({ title: termSearch.value }))
const termPagination = ref({ page: 1, perPage: 10 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const termOptions = computed(() => termsResponse.value?.data ?? [])
const templateSearch = ref('') const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value })) const templateFilters = computed(() => ({
const templatePagination = ref({ page: 1, perPage: 30 }) title: templateSearch.value,
const { data: templatesResponse } = useAdminCourseTemplatesListQuery( termId: form.value.termId || undefined,
templateFilters, }))
templatePagination const templatePagination = ref({ page: 1, perPage: 10 })
) const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination, {
enabled: () => !!form.value.termId,
})
const templateOptions = computed(() => templatesResponse.value?.data ?? []) const templateOptions = computed(() => templatesResponse.value?.data ?? [])
const sessionSearch = ref('') const sessionSearch = ref('')
const sessionListFilters = computed(() => ({ const sessionListFilters = computed(() => ({
title: sessionSearch.value, title: sessionSearch.value,
courseTemplateId: form.value.courseTemplateId || undefined, courseId: form.value.courseId || undefined,
})) }))
const sessionPagination = ref({ page: 1, perPage: 30 }) const sessionPagination = ref({ page: 1, perPage: 10 })
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionListFilters, sessionPagination) const { data: sessionsResponse } = useAdminSessionsListQuery(
sessionListFilters,
sessionPagination,
{
enabled: () => !!form.value.courseId,
}
)
const sessionOptions = computed(() => sessionsResponse.value?.data ?? []) const sessionOptions = computed(() => sessionsResponse.value?.data ?? [])
const searchTerms = useDebounce((q) => {
termSearch.value = q || ''
}, 400)
const searchTemplates = useDebounce((q) => { const searchTemplates = useDebounce((q) => {
templateSearch.value = q || '' templateSearch.value = q || ''
}, 400) }, 400)
@@ -128,6 +161,15 @@ const searchSessions = useDebounce((q) => {
sessionSearch.value = q || '' sessionSearch.value = q || ''
}, 400) }, 400)
const onTermChange = () => {
form.value.courseId = ''
form.value.sessionId = ''
}
const onCourseChange = () => {
form.value.sessionId = ''
}
const hasFilters = computed(() => const hasFilters = computed(() =>
Object.values(form.value).some((v) => v !== '' && v !== null && v !== undefined) Object.values(form.value).some((v) => v !== '' && v !== null && v !== undefined)
) )
@@ -161,7 +203,7 @@ const onReset = () => {
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(6, 1fr);
} }
} }
@@ -1,12 +1,14 @@
<template> <template>
<BasicModal width="95%" max-width="64rem" min-width="auto" :show-close-button="true"> <BasicModal
<template #default="{ close }">
<form class="add-assignment" @submit.prevent="onSubmit(close)">
<LineTitleBlock
:title="isEditMode ? 'ویرایش تکلیف' : 'افزودن تکلیف جدید'" :title="isEditMode ? 'ویرایش تکلیف' : 'افزودن تکلیف جدید'"
:title-en="isEditMode ? 'Edit Assignment' : 'Add Assignment'" :title-en="isEditMode ? 'Edit Assignment' : 'Add Assignment'"
/> width="95%"
max-width="64rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }">
<form class="add-assignment" @submit.prevent="onSubmit(close)">
<div class="add-assignment__grid"> <div class="add-assignment__grid">
<TextField <TextField
v-model="form.title" v-model="form.title"
@@ -20,15 +22,28 @@
</template> </template>
</TextField> </TextField>
<SelectField <SelectField
v-model="form.courseTemplateId" v-model="form.termId"
name="courseTemplateId" name="termId"
label="ترم مرتبط"
:options="termOptions"
option-label="title"
option-value="id"
:searchable="true"
:on-search="searchTerms"
:error="errors.termId"
@update:model-value="onTermChange"
/>
<SelectField
v-model="form.courseId"
name="courseId"
label="دوره مرتبط" label="دوره مرتبط"
:options="templateOptions" :options="templateOptions"
option-label="title" option-label="title"
option-value="id" option-value="id"
:searchable="true" :searchable="true"
:on-search="searchTemplates" :on-search="searchTemplates"
:error="errors.courseTemplateId" :disabled="!form.termId"
:error="errors.courseId"
@update:model-value="onCourseChange" @update:model-value="onCourseChange"
/> />
<SelectField <SelectField
@@ -40,25 +55,19 @@
option-value="id" option-value="id"
:searchable="true" :searchable="true"
:on-search="searchSessions" :on-search="searchSessions"
:disabled="!form.courseTemplateId" :disabled="!form.courseId"
:error="errors.sessionId" :error="errors.sessionId"
/> />
<DatePickerField <DatePickerField
v-model="form.startDate" v-model="form.deadline"
name="startDate" name="deadline"
label="تاریخ شروع" label="ددلاین"
:error="errors.startDate" :error="errors.deadline"
/>
<DatePickerField
v-model="form.endDate"
name="endDate"
label="تاریخ پایان"
:error="errors.endDate"
/> />
<SelectField <SelectField
v-model="form.priority" v-model="form.priority"
name="priority" name="priority"
label="اولویت" label="اولویت تکلیف"
:options="priorityOptions" :options="priorityOptions"
option-label="label" option-label="label"
option-value="value" option-value="value"
@@ -75,13 +84,13 @@
</div> </div>
<div class="add-assignment__files"> <div class="add-assignment__files">
<LineTitleBlock title="فایل‌های تکلیف" title-en="Assignment Files" />
<FileUploader <FileUploader
v-model="attachments" v-model="attachments"
accept=".mp4,.mov,.avi,.mp3,.wav,.jpg,.jpeg,.png,.pdf,.txt,.doc,.docx" accept=".mp4,.mov,.avi,.mp3,.wav,.jpg,.jpeg,.png,.pdf,.txt,.doc,.docx"
:multiple="true" :multiple="true"
:max-files="10" :max-files="10"
context="assignment" @select="onAttachmentsSelect"
@error="onAttachmentsError"
/> />
</div> </div>
@@ -115,30 +124,33 @@
</template> </template>
<script setup> <script setup>
import { toast } from 'vue3-toastify'
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import useModal from '@/composables/useModal'
import { ASSIGNMENT_PRIORITY } from '@/enums'
import useDebounce from '@/composables/useDebounce'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import TextField from '@/components/form/TextField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import SelectField from '@/components/form/SelectField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import TextField from '@/components/form/TextField.vue'
import useYup from '@/composables/useYup' import SelectField from '@/components/form/SelectField.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { assignmentSchema } from '@/features/admin/assignments/schema'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import { import {
adminAssignmentsKeys, adminAssignmentsKeys,
useAddAdminAssignmentMutation, useAddAdminAssignmentMutation,
useAdminAssignmentQuery, useAdminAssignmentQuery,
useUpdateAdminAssignmentMutation, useUpdateAdminAssignmentMutation,
} from '@/services/query/admin-assignments' } from '@/services/query/admin-assignments'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import useDebounce from '@/composables/useDebounce'
import { ASSIGNMENT_PRIORITY } from '@/enums'
import { assignmentSchema } from '@/features/admin/assignments/schema'
defineOptions({ name: 'AddAssignmentModal' }) defineOptions({ name: 'AddAssignmentModal' })
@@ -156,10 +168,10 @@ const priorityOptions = Object.entries(ASSIGNMENT_PRIORITY).map(([value, label])
const form = ref({ const form = ref({
title: '', title: '',
courseTemplateId: '', termId: '',
courseId: '',
sessionId: '', sessionId: '',
startDate: '', deadline: '',
endDate: '',
priority: 'mandatory', priority: 'mandatory',
description: '', description: '',
}) })
@@ -170,13 +182,28 @@ const schema = assignmentSchema
const { validate, validateAt, errors, resetErrors } = useYup(schema) const { validate, validateAt, errors, resetErrors } = useYup(schema)
const termSearch = ref('')
const termFilters = computed(() => ({ title: termSearch.value }))
const termPagination = ref({ page: 1, perPage: 10 })
const { data: termsResponse } = useAdminTermsListQuery(termFilters, termPagination)
const selectedTerm = ref(null)
const termOptions = computed(() => {
const base = termsResponse.value?.data ?? []
if (selectedTerm.value && !base.some((t) => t.id === selectedTerm.value.id)) {
return [...base, selectedTerm.value]
}
return base
})
const templateSearch = ref('') const templateSearch = ref('')
const templateFilters = computed(() => ({ title: templateSearch.value })) const templateFilters = computed(() => ({
const templatePagination = ref({ page: 1, perPage: 30 }) title: templateSearch.value,
const { data: templatesResponse } = useAdminCourseTemplatesListQuery( termId: form.value.termId || undefined,
templateFilters, }))
templatePagination const templatePagination = ref({ page: 1, perPage: 10 })
) const { data: templatesResponse } = useAdminCoursesListQuery(templateFilters, templatePagination, {
enabled: () => !!form.value.termId,
})
const selectedTemplate = ref(null) const selectedTemplate = ref(null)
const templateOptions = computed(() => { const templateOptions = computed(() => {
const base = templatesResponse.value?.data ?? [] const base = templatesResponse.value?.data ?? []
@@ -189,11 +216,11 @@ const templateOptions = computed(() => {
const sessionSearch = ref('') const sessionSearch = ref('')
const sessionFilters = computed(() => ({ const sessionFilters = computed(() => ({
title: sessionSearch.value, title: sessionSearch.value,
courseTemplateId: form.value.courseTemplateId, courseId: form.value.courseId,
})) }))
const sessionPagination = ref({ page: 1, perPage: 30 }) const sessionPagination = ref({ page: 1, perPage: 10 })
const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination, { const { data: sessionsResponse } = useAdminSessionsListQuery(sessionFilters, sessionPagination, {
enabled: () => !!form.value.courseTemplateId, enabled: () => !!form.value.courseId,
}) })
const selectedSession = ref(null) const selectedSession = ref(null)
const sessionOptions = computed(() => { const sessionOptions = computed(() => {
@@ -204,6 +231,9 @@ const sessionOptions = computed(() => {
return base return base
}) })
const searchTerms = useDebounce((q) => {
termSearch.value = q || ''
}, 400)
const searchTemplates = useDebounce((q) => { const searchTemplates = useDebounce((q) => {
templateSearch.value = q || '' templateSearch.value = q || ''
}, 400) }, 400)
@@ -211,40 +241,84 @@ const searchSessions = useDebounce((q) => {
sessionSearch.value = q || '' sessionSearch.value = q || ''
}, 400) }, 400)
const onTermChange = (value) => {
form.value.termId = value
form.value.courseId = ''
form.value.sessionId = ''
selectedTemplate.value = null
selectedSession.value = null
}
const onCourseChange = (value) => { const onCourseChange = (value) => {
form.value.courseTemplateId = value form.value.courseId = value
form.value.sessionId = '' form.value.sessionId = ''
selectedSession.value = null selectedSession.value = null
} }
const uploadMediaMutation = useUploadMediaMutation()
const onAttachmentsSelect = async (files) => {
for (const file of files) {
try {
const fd = objectToFormData({ file, purpose: 'homework_file', context: 'homework' })
const response = await uploadMediaMutation.mutateAsync(fd)
const payload = response?.data ?? response
const id = payload?.id ?? payload?.uploadId
if (id == null) continue
attachments.value = [
...attachments.value,
{ id, name: file.name, size: file.size, url: payload?.url ?? '' },
]
} catch {
toast.error(`بارگذاری فایل "${file.name}" با خطا مواجه شد.`)
}
}
}
const onAttachmentsError = (msg) => toast.error(msg)
const { data: existingAssignment } = useAdminAssignmentQuery(assignmentId, { const { data: existingAssignment } = useAdminAssignmentQuery(assignmentId, {
enabled: () => !!assignmentId.value, enabled: () => !!assignmentId.value,
}) })
watch(existingAssignment, (assignment) => { watch(
existingAssignment,
(assignment) => {
if (!assignment) return if (!assignment) return
const tpl = assignment.courseTemplate
const sessionEntity = assignment.session const sessionEntity = assignment.session
const tpl = sessionEntity?.course || assignment.course
const termEntity = tpl?.term || assignment.term
const termId = termEntity?.id || tpl?.termId || assignment.termId || ''
const priorityValue =
assignment.priority ||
(typeof assignment.isPriority === 'boolean'
? assignment.isPriority
? 'mandatory'
: 'optional'
: 'mandatory')
if (termEntity) selectedTerm.value = termEntity
if (tpl) selectedTemplate.value = tpl if (tpl) selectedTemplate.value = tpl
if (sessionEntity) selectedSession.value = sessionEntity if (sessionEntity) selectedSession.value = sessionEntity
form.value = { form.value = {
title: assignment.title || '', title: assignment.title || '',
courseTemplateId: tpl?.id || assignment.courseTemplateId || '', termId,
courseId: tpl?.id || sessionEntity?.courseId || assignment.courseId || '',
sessionId: sessionEntity?.id || assignment.sessionId || '', sessionId: sessionEntity?.id || assignment.sessionId || '',
startDate: assignment.startDate || '', deadline: assignment.deadline || assignment.endDate || '',
endDate: assignment.endDate || '', priority: priorityValue,
priority: assignment.priority || 'mandatory',
description: assignment.description || '', description: assignment.description || '',
} }
attachments.value = Array.isArray(assignment.attachments) attachments.value = Array.isArray(assignment.media)
? assignment.attachments.map((file) => ({ ? assignment.media.map((m) => ({
id: file.id, id: m.id,
name: file.title || `فایل ${file.id}`, name: m.fileName || `فایل ${m.id}`,
url: file.fileUrl || '', size: m.fileSize ?? 0,
type: file.type || 'other', url: m.url || m.downloadUrl || '',
})) }))
: [] : []
}) },
{ immediate: false }
)
const addMutation = useAddAdminAssignmentMutation() const addMutation = useAddAdminAssignmentMutation()
const updateMutation = useUpdateAdminAssignmentMutation() const updateMutation = useUpdateAdminAssignmentMutation()
@@ -255,13 +329,13 @@ const onSubmit = async (close) => {
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
const finalPayload = { const finalPayload = {
...payload, sessionId: payload.sessionId,
attachments: attachments.value.map((file, index) => ({ title: payload.title,
fileId: file.id, description: payload.description ?? '',
type: file.type || 'other', deadline: payload.deadline || null,
title: file.name, isActive: true,
order: index + 1, isPriority: payload.priority === 'mandatory',
})), mediaIds: attachments.value.map((file) => file.id).filter((id) => id != null),
} }
if (isEditMode.value) { if (isEditMode.value) {
await updateMutation.mutateAsync({ id: assignmentId.value, payload: finalPayload }) await updateMutation.mutateAsync({ id: assignmentId.value, payload: finalPayload })
@@ -310,8 +384,8 @@ const onSubmit = async (close) => {
&__actions { &__actions {
display: flex; display: flex;
justify-content: flex-end; align-items: center;
gap: 0.625rem; justify-content: space-between;
} }
&__btn-cancel { &__btn-cancel {
@@ -14,35 +14,17 @@
<div class="assignment-details__head"> <div class="assignment-details__head">
<p class="assignment-details__title">{{ assignment.title || '—' }}</p> <p class="assignment-details__title">{{ assignment.title || '—' }}</p>
<p class="assignment-details__sub"> <p class="assignment-details__sub">
<span> <span>دوره: {{ courseTitle }}</span>
دوره:
{{ assignment.courseTemplate?.title || assignment.courseTemplateTitle || '—' }}
</span>
<span class="assignment-details__sep">|</span> <span class="assignment-details__sep">|</span>
<span>جلسه: {{ assignment.session?.title || assignment.sessionTitle || '—' }}</span> <span>جلسه: {{ sessionTitle }}</span>
</p> </p>
</div> </div>
<div class="assignment-details__grid"> <div class="assignment-details__grid">
<LineInfoBlock <LineInfoBlock title="تاریخ پایان" :numeric-desc="deadlineLabel" />
title="تاریخ شروع" <LineInfoBlock title="مدت زمان" :numeric-desc="durationLabel" />
:numeric-desc="formatJalaaliDate(assignment.startDate) || '—'" <LineInfoBlock title="اولویت" :desc="priorityLabel" />
/> <LineInfoBlock title="تعداد فرستندگان" :numeric-desc="submittersLabel" />
<LineInfoBlock
title="تاریخ پایان"
:numeric-desc="formatJalaaliDate(assignment.endDate) || '—'"
/>
<LineInfoBlock
title="مدت زمان"
:numeric-desc="
assignment.durationDays != null ? `${assignment.durationDays} روز` : '—'
"
/>
<LineInfoBlock title="اولویت" :desc="ASSIGNMENT_PRIORITY[assignment.priority] || '—'" />
<LineInfoBlock
title="تعداد فرستندگان"
:numeric-desc="assignment.submissionsCount ?? 0"
/>
<LineInfoBlock <LineInfoBlock
title="تاریخ ثبت" title="تاریخ ثبت"
:numeric-desc=" :numeric-desc="
@@ -55,18 +37,18 @@
<LineInfoBlock title="توضیحات" :desc="assignment.description" /> <LineInfoBlock title="توضیحات" :desc="assignment.description" />
</div> </div>
<div v-if="assignment.attachments?.length" class="assignment-details__attachments"> <div v-if="attachmentItems.length > 0" class="assignment-details__attachments">
<LineTitleBlock title="فایل‌های تکلیف" title-en="Attachments" /> <LineTitleBlock title="فایل‌های تکلیف" title-en="Attachments" />
<ul class="assignment-details__attachments-list"> <ul class="assignment-details__attachments-list">
<li v-for="file in assignment.attachments" :key="file.id"> <li v-for="file in attachmentItems" :key="file.id">
<a <a
:href="file.fileUrl || '#'" :href="file.url || '#'"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="assignment-details__file" class="assignment-details__file"
> >
<SvgIcon name="file" :size="16" color="var(--color-prim-gray)" /> <SvgIcon name="file" :size="16" color="var(--color-prim-gray)" />
<span>{{ file.title || `فایل ${file.id}` }}</span> <span>{{ file.title }}</span>
</a> </a>
</li> </li>
</ul> </ul>
@@ -90,18 +72,17 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import useModal from '@/composables/useModal'
import { ASSIGNMENT_PRIORITY } from '@/enums'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
import LineTitleBlock from '@/components/LineTitleBlock.vue' import LineTitleBlock from '@/components/LineTitleBlock.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue' import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal'
import { useAdminAssignmentQuery } from '@/services/query/admin-assignments' import { useAdminAssignmentQuery } from '@/services/query/admin-assignments'
import { ASSIGNMENT_PRIORITY } from '@/enums' import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { formatJalaaliDate } from '@/utils/date-utils'
defineOptions({ name: 'AssignmentDetailsModal' }) defineOptions({ name: 'AssignmentDetailsModal' })
@@ -113,6 +94,65 @@ const assignmentId = computed(() => modalData.value.id ?? null)
const { data: assignment, isLoading } = useAdminAssignmentQuery(assignmentId, { const { data: assignment, isLoading } = useAdminAssignmentQuery(assignmentId, {
enabled: () => !!assignmentId.value, enabled: () => !!assignmentId.value,
}) })
const courseTitle = computed(
() =>
assignment.value?.session?.course?.title ||
assignment.value?.course?.title ||
assignment.value?.courseTitle ||
'—'
)
const sessionTitle = computed(
() => assignment.value?.session?.title || assignment.value?.sessionTitle || '—'
)
const deadlineRaw = computed(() => assignment.value?.endDate || assignment.value?.deadline || '')
const deadlineLabel = computed(() => formatJalaaliDate(deadlineRaw.value) || '—')
const durationLabel = computed(() => {
if (assignment.value?.durationDays != null) return `${assignment.value.durationDays} روز`
const start = assignment.value?.startDate || assignment.value?.createdAt
const end = deadlineRaw.value
if (!start || !end) return '—'
const diff = Math.ceil((new Date(end).getTime() - new Date(start).getTime()) / 86_400_000)
return diff >= 0 ? `${diff} روز` : '—'
})
const priorityLabel = computed(() => {
const priority =
assignment.value?.priority ||
(typeof assignment.value?.isPriority === 'boolean'
? assignment.value.isPriority
? 'mandatory'
: 'optional'
: null)
return ASSIGNMENT_PRIORITY[priority] || '—'
})
const submittersLabel = computed(
() => assignment.value?.submittersCount ?? assignment.value?.submissionsCount ?? 0
)
const attachmentItems = computed(() => {
const media = assignment.value?.media
if (Array.isArray(media) && media.length > 0) {
return media.map((m) => ({
id: m.id,
title: m.fileName || `فایل ${m.id}`,
url: m.url || m.downloadUrl || '',
}))
}
const legacy = assignment.value?.attachments
if (Array.isArray(legacy)) {
return legacy.map((a) => ({
id: a.id,
title: a.title || `فایل ${a.id}`,
url: a.fileUrl || '',
}))
}
return []
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -1,9 +1,14 @@
<template> <template>
<BasicModal width="95%" max-width="55rem" min-width="auto" :show-close-button="true"> <BasicModal
title="جزئیات تکلیف"
title-en="Submission Details"
width="95%"
max-width="55rem"
min-width="auto"
:show-close-button="true"
>
<template #default="{ close }"> <template #default="{ close }">
<div class="submission-details"> <div class="submission-details">
<LineTitleBlock title="جزئیات تکلیف" title-en="Submission Details" />
<SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" /> <SkeletonLoaderBlock v-if="isLoading" :rows="3" :cols-per-row="1" />
<template v-else-if="submission"> <template v-else-if="submission">
<div class="submission-details__person"> <div class="submission-details__person">
@@ -35,7 +40,7 @@
<div class="submission-details__assignment-meta"> <div class="submission-details__assignment-meta">
<span>ترم: {{ submission.termTitle || '—' }}</span> <span>ترم: {{ submission.termTitle || '—' }}</span>
<span class="submission-details__sep">|</span> <span class="submission-details__sep">|</span>
<span>دوره: {{ submission.courseTemplateTitle || '—' }}</span> <span>دوره: {{ submission.courseTitle || '—' }}</span>
<span class="submission-details__sep">|</span> <span class="submission-details__sep">|</span>
<span>جلسه: {{ submission.sessionTitle || '—' }}</span> <span>جلسه: {{ submission.sessionTitle || '—' }}</span>
<span class="submission-details__sep">|</span> <span class="submission-details__sep">|</span>
@@ -44,7 +49,7 @@
</div> </div>
<div class="submission-details__row"> <div class="submission-details__row">
<LineInfoBlock title="پاسخ دانشجو" :desc="submission.userDescription || '—'" /> <LineInfoBlock title="پاسخ طلبه" :desc="submission.userDescription || '—'" />
</div> </div>
<div v-if="submission.attachments?.length" class="submission-details__attachments"> <div v-if="submission.attachments?.length" class="submission-details__attachments">
@@ -125,28 +130,27 @@
</template> </template>
<script setup> <script setup>
import useYup from '@/composables/useYup'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import useModal from '@/composables/useModal'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import BasicModal from '@/components/BasicModal.vue' import BasicModal from '@/components/BasicModal.vue'
import BaseButton from '@/components/BaseButton.vue' import BaseButton from '@/components/BaseButton.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import NoItems from '@/components/blocks/NoItems.vue'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import TextField from '@/components/form/TextField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import SelectField from '@/components/form/SelectField.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue' import SvgIcon from '@/components/icons/SvgIcon.vue'
import useModal from '@/composables/useModal' import NoItems from '@/components/blocks/NoItems.vue'
import useYup from '@/composables/useYup' import { formatJalaaliDate } from '@/utils/date-utils'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import LineInfoBlock from '@/components/blocks/LineInfoBlock.vue'
import { EDUCATION_STATUS, SEMINARY_LEVEL, UNIVERSITY_LEVEL } from '@/enums'
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
import { assignmentSubmissionReviewSchema } from '@/features/admin/assignments/schema'
import { import {
adminAssignmentsKeys, adminAssignmentsKeys,
useAdminAssignmentSubmissionQuery, useAdminAssignmentSubmissionQuery,
useReviewAdminAssignmentSubmissionMutation, useReviewAdminAssignmentSubmissionMutation,
} from '@/services/query/admin-assignments' } from '@/services/query/admin-assignments'
import { EDUCATION_STATUS, SEMINARY_LEVEL, UNIVERSITY_LEVEL } from '@/enums'
import { assignmentSubmissionReviewSchema } from '@/features/admin/assignments/schema'
import { formatJalaaliDate } from '@/utils/date-utils'
defineOptions({ name: 'AssignmentSubmissionDetailsModal' }) defineOptions({ name: 'AssignmentSubmissionDetailsModal' })

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