This commit is contained in:
sajjadtalkhabi
2026-05-15 17:12:51 +03:30
parent b8ced45375
commit c82701888a
101 changed files with 8125 additions and 96 deletions
+1
View File
@@ -79,6 +79,7 @@ const handleClick = (event) => {
font-family: var(--font-family-fa);
text-decoration: none;
padding: 0 1rem;
white-space: nowrap;
&__text {
line-height: 1.25rem;
+113 -31
View File
@@ -1,23 +1,57 @@
<template>
<div class="tabs-block">
<div class="tabs-block__nav">
<button
<div class="tabs-block__tabs">
<button
v-for="tab in tabs"
:key="tab.name"
type="button"
class="tabs-block__tab"
:class="{ 'tabs-block__tab--active': activeTab === tab.name }"
@click="onChange(tab.name)"
>
<span class="tabs-block__tab-card">
<SvgIcon v-if="tab.icon" :name="tab.icon" :size="16" color="#bcbcbc" />
<span class="tabs-block__tab-label">{{ tab.label }}</span>
</span>
</button>
</div>
<div
v-for="tab in tabs"
:key="tab.name"
type="button"
class="tabs-block__btn"
:class="{ 'tabs-block__btn--active': activeTab === tab.name }"
@click="onChange(tab.name)"
:key="`btn-${tab.name}`"
class="tabs-block__action tabs-block__action--desktop"
>
<SvgIcon
v-if="tab.icon"
:name="tab.icon"
:size="16"
:color="activeTab === tab.name ? 'var(--color-primary)' : '#bcbcbc'"
/>
<span>{{ tab.label }}</span>
</button>
<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>
</div>
<div
v-for="tab in tabs"
: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>
<div class="tabs-block__content">
<slot :name="activeTab" />
</div>
@@ -26,7 +60,7 @@
<script setup>
import { ref, watch } from 'vue'
import BaseButton from '@/components/BaseButton.vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
const props = defineProps({
@@ -51,39 +85,87 @@ const onChange = (name) => {
emit('update:modelValue', name)
emit('change-tab', name)
}
const onActionClick = (tab) => {
tab.buttonAction?.(tab)
emit('button-click', tab)
}
</script>
<style lang="scss" scoped>
.tabs-block {
&__nav {
display: flex;
gap: 0.5rem;
border-block-end: 1px solid #eee;
gap: 0.625rem;
margin-bottom: 1rem;
overflow: auto hidden;
}
&__btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1rem;
&__tabs {
flex: 1;
display: flex;
gap: 0.625rem;
}
&__tab {
flex: 1;
border: none;
background: transparent;
padding: 0;
cursor: pointer;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: #848484;
border-block-end: 2px solid transparent;
margin-block-end: -1px;
white-space: nowrap;
transition: opacity 0.15s ease;
opacity: 0.6;
&--active {
color: var(--color-primary);
border-color: var(--color-primary);
opacity: 1;
}
}
&__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.875rem;
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 {
padding: 0 1.25rem;
min-width: fit-content;
}
&__content {
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>
+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: #999999;
border-color: #999999;
}
&__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>
+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: #eeeeee;
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>
+422
View File
@@ -0,0 +1,422 @@
<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__preview">
<button type="button" class="voice-recorder__main-btn" @click="togglePlay">
<SvgIcon
:name="isPlaying ? 'close' : 'paper-plane-right'"
:size="40"
color="var(--color-thd-gray)"
/>
</button>
<audio
ref="audioEl"
:src="audioUrl"
class="voice-recorder__audio"
@ended="onEnded"
@timeupdate="onTimeUpdate"
/>
</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"
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>
<div v-if="audioUrl" class="voice-recorder__progress">
<div class="voice-recorder__progress-fill" :style="{ width: `${progress}%` }" />
</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 SvgIcon from '@/components/icons/SvgIcon.vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import PermissionModal from '@/features/auth/components/studentRegister/PermissionModal.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 audioEl = ref(null)
const fileInput = ref(null)
let mediaRecorder = null
let mediaStream = null
let chunks = []
let timerHandle = null
let timerStart = 0
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
isPlaying.value = false
emit('update:modelValue', file)
emit('recorded', file)
}
const startRecording = async () => {
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
chunks = []
mediaRecorder = new window.MediaRecorder(mediaStream)
mediaRecorder.ondataavailable = (e) => {
if (e.data?.size > 0) chunks.push(e.data)
}
mediaRecorder.onstop = () => {
isRecording.value = false
stopTracks()
const blob = new Blob(chunks, { type: 'audio/webm' })
const file = new File([blob], `recording-${Date.now()}.webm`, {
type: 'audio/webm',
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
}
const onTimeUpdate = () => {
const a = audioEl.value
if (!a?.duration) return
progress.value = (a.currentTime / a.duration) * 100
}
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;
}
}
&__preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
&__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;
}
&__progress {
width: 100%;
height: 0.125rem;
border-radius: 9999px;
background: var(--color-thd-gray);
overflow: hidden;
}
&__progress-fill {
height: 100%;
background: rgba(0, 0, 0, 40%);
border-radius: 9999px;
transition: width 0.2s ease;
}
&__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>
+5 -1
View File
@@ -14,10 +14,13 @@ import { computed } from 'vue'
import { iconRegistry } from '@/components/icons/registry'
/** @typedef {import('@/components/icons/icon-names').IconName} IconName */
const props = defineProps({
/** @type {import('vue').PropType<IconName>} */
name: { type: String, required: true },
size: { type: [String, Number], default: '1.25rem' },
color: { type: String, default: 'currentColor' },
color: { type: String, default: 'transparent' },
})
const component = computed(() => {
@@ -25,6 +28,7 @@ const component = computed(() => {
if (!c && import.meta.env.DEV) {
console.warn(`[SvgIcon] unknown icon "${props.name}"`)
}
return c
})
+48
View File
@@ -0,0 +1,48 @@
// 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'
| 'caret-down'
| 'caret-left'
| 'caret-right'
| 'chat'
| 'chat-centered-dots'
| 'check'
| 'check-square'
| 'close'
| 'copy'
| 'eye'
| 'eye-slash'
| 'file'
| 'funnel'
| 'heart'
| 'instagram'
| 'link'
| 'list-bullets'
| 'map-pin-simple-area'
| 'menu'
| 'microphone'
| 'mood'
| 'paper-plane'
| 'paper-plane-right'
| 'pencil'
| 'phone'
| 'plus'
| 'scroll'
| 'spinner'
| 'square'
| 'telegram'
| 'trash'
| 'upload'
| 'user'
| 'users'
| 'users-three'
| 'warning'
export const iconNames: readonly IconName[]