jobdone-shared-files 1.1.12 → 1.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,400 @@
1
+ <template>
2
+ <div id="ModalFileRepository" class="modal fade" :class="optionsAdjust.subModal ? 'sub-modal' : ''" ref="modalDom"
3
+ data-bs-backdrop="static" tabindex="-1" role="dialog" aria-hidden="true">
4
+ <div class="modal-dialog modal-dialog-scrollable" :class="classManager.modalSize" role="document">
5
+ <div class="modal-content">
6
+ <div class="modal-header">
7
+ <h5 class="modal-title"><span class="material-icons icon-18 me-2 text-primary">{{
8
+ optionsAdjust.modalIcon }}</span>{{ optionsAdjust.modalTitle }}</h5>
9
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
10
+ </div>
11
+ <div class="top-header">
12
+ <p>暫存清單</p>
13
+ <template v-if="selectedFilesSet.size > 0">
14
+ <div class="d-flex align-items-center gap-2">
15
+ <small><span>已選擇</span><span class="text-primary mx-1">{{ selectedFilesSet.size
16
+ }}</span><span>個檔案</span></small>
17
+ <button class="btn btn-link btn-sm" @click='clearSelected'><span
18
+ class="material-icons me-1">close</span>取消選取</button>
19
+ </div>
20
+ </template>
21
+ <template v-else>
22
+ <div class="form-check" v-if="optionsAdjust.onlyMyFileCanSwitch">
23
+ <input class="form-check-input" type="checkbox" :true-value="true" :false-value="false"
24
+ :id="`ModalFileRepository_MyFilesOnly_${componentUuid}`" v-model="onlyMyFile"
25
+ @change="reloadRepositoryFiles()" />
26
+ <label class="form-check-label is-unselectable"
27
+ :for="`ModalFileRepository_MyFilesOnly_${componentUuid}`">僅顯示我的檔案</label>
28
+ </div>
29
+ </template>
30
+ </div>
31
+ <div class="modal-body overflow-auto p-0" ref="previewWindow" style="position: relative;">
32
+ <div class="the-month-content m-3" v-for="theMonth in fileListPreview" :key="theMonth.monthKey">
33
+ <p class="the-month-content-header">{{ theMonth.monthLabel }}</p>
34
+ <div class="the-month-content-body">
35
+ <template v-for="(day, dayIdx) in theMonth.days"
36
+ :key="`${theMonth.monthKey}-${day.dayKey}`">
37
+ <div class="the-date-content" v-for="(item, itemIdx) in day.items"
38
+ :key="`${theMonth.monthKey}-${day.dayKey}-${itemIdx}`">
39
+ <p class="the-date-content-header" v-if="itemIdx === 0">{{ day.dayLabel }}</p>
40
+ <div class="the-date-content-body">
41
+ <div class="multimedia-select-content">
42
+ <button class="select-control"
43
+ @click.stop="selectMultimedia(item[optionsAdjust.bindingKey])"
44
+ :class="{ 'is-selected': selectedFilesSet.has(item[optionsAdjust.bindingKey]) }"></button>
45
+ </div>
46
+ <button class="multimedia-preview-content"
47
+ @click="previewImg(item[optionsAdjust.fileUrlKey], item.fileName)">
48
+ <div v-if="item.multimediaList?.length > 0"
49
+ class="the-date-content-multiple-tag">
50
+ </div>
51
+ <img :src="item[optionsAdjust.thumbnailUrlKey] || item[optionsAdjust.fileUrlKey]"
52
+ lazy="loaded" :alt="item.fileName" :title="item.fileName">
53
+ </button>
54
+ </div>
55
+ </div>
56
+ </template>
57
+ </div>
58
+ </div>
59
+ <div v-if="fileListPreview.length === 0" class="border-bottom text-center p-4 bg-gray-500">
60
+ <p class="mt-3">暫存媒體庫中沒有任何檔案</p>
61
+ </div>
62
+ <div ref="getNextPagesTarget" style="height: 1rem"></div>
63
+ </div>
64
+ <div class="modal-footer">
65
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
66
+ <button type="button" class="btn btn-primary" @click="submitSelected">送出選擇項目 ( {{
67
+ selectedFilesSet.size }}
68
+ )</button>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ <vue-easy-lightbox ref="ezLb" :visible='previewImgAry.length > 0' :imgs="previewImgAry" :index="previewImgIdx"
74
+ @hide="onHide"></vue-easy-lightbox>
75
+ <Loading :is-active="isLoading"></Loading>
76
+ </template>
77
+ <script setup>
78
+ import axios from 'axios';
79
+ import { computed, ref, nextTick, toRaw, onMounted } from "vue";
80
+ import { modalShow, modalHide } from "./common/ModalSetup.js";
81
+ import Loading from "./vueLoadingOverlay.vue";
82
+ import VueEasyLightbox from 'vue-easy-lightbox';
83
+
84
+ const props = defineProps({
85
+ projectId: {
86
+ // 專案ID
87
+ type: String,
88
+ required: true
89
+ },
90
+ userId: {
91
+ // 使用者ID
92
+ type: String,
93
+ required: true
94
+ },
95
+ apiUrl: {
96
+ // 取得檔案列表的 API URL
97
+ type: String,
98
+ required: true
99
+ },
100
+ options: {
101
+ // 額外參數設定
102
+ // {
103
+ // subModal: false, // 是否為子視窗(預設 false),子視窗會取消 backdrop-filter 效果,且高度略低於一般Modal
104
+ // modalSize: 'lg' // Modal的尺寸。可選值:sm, md, lg, xl。預設 'lg',
105
+ // modalTitle: '暫存媒體選擇器' // Modal的標題。預設 '暫存媒體選擇器'
106
+ // modalIcon: 'inventory_2', // Modal的圖示。預設 'inventory_2'
107
+ // onlyMyFileCanSwitch: true, // 是否顯示「僅顯示我的檔案」切換選項。預設 true
108
+ // onlyMyFileDefaultValue: true, // 「僅顯示我的檔案」預設選項。預設 true
109
+ // bindingKey: 'uid', // 選項唯一值的keyName。預設 'uid'
110
+ // createAtKey: 'createAt' // 檔案建立時間的keyName。預設 'createAt'
111
+ // fileUrlKey: 'fileUrl', // 檔案URL的keyName。預設'fileUrl'
112
+ // thumbnailUrlKey: 'thumbnailUrl', // 檔案縮圖URL的keyName。預設'thumbnailUrl'
113
+ // }
114
+ type: Object,
115
+ default: () => ({})
116
+ }
117
+ })
118
+ const emit = defineEmits([
119
+ 'submitSelected'
120
+ ])
121
+
122
+ const componentUuid = crypto.randomUUID()
123
+ const modalDom = ref(null)
124
+ const isLoading = ref(false)
125
+
126
+ // 參數設定
127
+ const optionConfigs = {
128
+ bindingKey: {
129
+ validate: (value) => typeof value === 'string' && value.length > 0,
130
+ defaultValue: 'uid'
131
+ },
132
+ createAtKey: {
133
+ validate: (value) => typeof value === 'string' && value.length > 0,
134
+ defaultValue: 'createAt'
135
+ },
136
+ fileUrlKey: {
137
+ validate: (value) => typeof value === 'string' && value.length > 0,
138
+ defaultValue: 'fileUrl'
139
+ },
140
+ thumbnailUrlKey: {
141
+ validate: (value) => typeof value === 'string' && value.length > 0,
142
+ defaultValue: 'thumbnailUrl'
143
+ },
144
+ subModal: {
145
+ validate: (value) => typeof value === 'boolean',
146
+ defaultValue: false // 是否為子視窗(預設 false),子視窗會取消 backdrop-filter 效果,且高度略低於一般Modal
147
+ },
148
+ modalSize: {
149
+ validate: (value) => typeof value === 'string' && ['sm', 'md', 'lg', 'xl'].includes(value),
150
+ defaultValue: 'lg' // 可選值:sm, md, lg, xl
151
+ },
152
+ modalTitle: {
153
+ validate: (value) => typeof value === 'string' && value.length > 0,
154
+ defaultValue: '暫存媒體選擇器'
155
+ },
156
+ modalIcon: {
157
+ validate: (value) => typeof value === 'string' && value.length > 0,
158
+ defaultValue: 'inventory_2'
159
+ },
160
+ onlyMyFileCanSwitch: {
161
+ validate: (value) => typeof value === 'boolean',
162
+ defaultValue: true // 是否顯示「僅顯示我的檔案」切換選項
163
+ },
164
+ onlyMyFileDefaultValue: {
165
+ validate: (value) => typeof value === 'boolean',
166
+ defaultValue: true // 「僅顯示我的檔案」預設選項。預設 true
167
+ }
168
+ }
169
+ const optionDefaults = Object.fromEntries(
170
+ Object.entries(optionConfigs).map(([key, config]) => [key, config.defaultValue])
171
+ )
172
+ const optionsAdjust = computed(() => {
173
+ const merged = { ...optionDefaults }
174
+ const incomingOptions = props.options || {}
175
+ for (const [key, value] of Object.entries(incomingOptions)) {
176
+ const config = optionConfigs[key]
177
+ if (!config) {
178
+ console.warn(`ModalFileRepositorySelector: 未知參數 ${key}`)
179
+ continue
180
+ }
181
+ if (!config.validate(value)) {
182
+ console.warn(`ModalFileRepositorySelector: 參數 ${key} 的型態錯誤,將使用預設值 ${config.defaultValue}`)
183
+ continue
184
+ }
185
+ merged[key] = value
186
+ }
187
+ return merged
188
+ })
189
+ const classManager = computed(() => {
190
+ return {
191
+ modalSize: optionsAdjust.value.modalSize ? `modal-${optionsAdjust.value.modalSize}` : `modal-${optionDefaults.modalSize}`
192
+ }
193
+ })
194
+
195
+ const ezLb = ref(null)
196
+ const previewImgAry = ref([])
197
+ const previewImgIdx = ref(0)
198
+ const previewImg = (url, title = '') => { previewImgAry.value = [{ src: url, title: title }] }
199
+ const onHide = () => { previewImgIdx.value = 0; previewImgAry.value = [] }
200
+
201
+ const debounce = (func, delay = 200) => {
202
+ let timeout;
203
+
204
+ return function (...args) {
205
+ const context = this;
206
+ clearTimeout(timeout); // 清除上一次的計時器
207
+ timeout = setTimeout(() => func.apply(context, args), delay);
208
+ };
209
+ }
210
+
211
+
212
+ // 滾動讀取
213
+ let observer = null
214
+ const previewWindow = ref(null)
215
+ const getNextPagesTarget = ref(null)
216
+ const checkNeedLoadMore = () => { // 檢查是否需要繼續載入(如果內容高度不足以觸發滾動)
217
+ if (getNextPagesTarget.value && getNextPagesTarget.value.getBoundingClientRect().top <= window.innerHeight) {
218
+ getRepositoryFiles()
219
+ }
220
+ }
221
+
222
+ // 開啟光箱
223
+ const show = async (selectedFilesUid) => {
224
+ modalShow(modalDom.value, afterCloseModal)
225
+ if (selectedFilesUid && selectedFilesUid.length > 0) {
226
+ selectedFilesSet.value = new Set(selectedFilesUid)
227
+ }
228
+ if (!observer) {
229
+ observer = new IntersectionObserver((entries) => {
230
+ entries.forEach(entry => {
231
+ if (entry.isIntersecting && (getNextPagesTarget.value && getNextPagesTarget.value.getBoundingClientRect().top <= window.innerHeight)) {
232
+ getRepositoryFiles()
233
+ }
234
+ });
235
+ }, {
236
+ root: previewWindow.value,
237
+ });
238
+ }
239
+ observer.observe(getNextPagesTarget.value);
240
+ }
241
+
242
+ // 關閉光箱時的處理
243
+ const afterCloseModal = () => {
244
+ observer.disconnect()
245
+ selectedFilesSet.value.clear()
246
+ }
247
+
248
+ // 載入檔案
249
+ const repositoryFiles = ref([])
250
+ const fileListPreview = computed(() => { // 預覽用陣列
251
+ const flatMap = repositoryFiles.value.flatMap(pageGroups => pageGroups.data.map(item => ({ ...item, page: pageGroups.page })))
252
+
253
+ // 先按日期排序(最新的在前)
254
+ const sortedItems = flatMap.sort((a, b) => new Date(b[optionsAdjust.value.createAtKey]) - new Date(a[optionsAdjust.value.createAtKey]))
255
+
256
+ // 分組處理
257
+ const groupedData = []
258
+ const monthGroupMap = new Map() // 用 Map 來快速查找月份群組
259
+ const dayGroupMap = new Map() // 用 Map 來快速查找日期群組
260
+
261
+ sortedItems.forEach(item => {
262
+ const date = new Date(item[[optionsAdjust.value.createAtKey]])
263
+ const year = date.getFullYear()
264
+ const month = date.getMonth() + 1
265
+ const day = date.getDate()
266
+
267
+ const monthKey = `${year}-${month.toString().padStart(2, '0')}`
268
+ const dayKey = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
269
+
270
+ // 檢查是否需要新增月份群組
271
+ let currentMonthGroup = monthGroupMap.get(monthKey)
272
+ if (!currentMonthGroup) {
273
+ currentMonthGroup = {
274
+ monthKey,
275
+ monthLabel: `${year}年${month}月`,
276
+ days: []
277
+ }
278
+ monthGroupMap.set(monthKey, currentMonthGroup)
279
+ groupedData.push(currentMonthGroup)
280
+ }
281
+
282
+ // 檢查是否需要新增日期群組
283
+ let currentDayGroup = dayGroupMap.get(dayKey)
284
+ if (!currentDayGroup) {
285
+ currentDayGroup = {
286
+ dayKey,
287
+ dayLabel: `${month}月${day}日`,
288
+ items: []
289
+ }
290
+ dayGroupMap.set(dayKey, currentDayGroup)
291
+ currentMonthGroup.days.push(currentDayGroup)
292
+ }
293
+ // 加入項目到日期群組
294
+ currentDayGroup.items.push(item)
295
+ })
296
+ return groupedData
297
+ })
298
+ const onlyMyFile = ref(true)
299
+ const repositoryFilesPagination = ref({
300
+ pagesToLoad: new Set([1]), // 應載入的頁數
301
+ totalPages: undefined,
302
+ size: 20
303
+ })
304
+ const getRepositoryFiles = async () => {
305
+ isLoading.value = true
306
+ try {
307
+ const fileListPages = new Set(repositoryFiles.value.map(pg => pg.page)) // 已載入頁數
308
+ let needLoadPages = Array.from(repositoryFilesPagination.value.pagesToLoad).filter(pg => !fileListPages.has(pg)) // 需要載入的頁數
309
+ if (!(repositoryFilesPagination.value.totalPages === undefined)) {
310
+ needLoadPages = needLoadPages.filter(pg => pg <= repositoryFilesPagination.value.totalPages) // 過濾超出總頁數的
311
+ }
312
+ if (needLoadPages.length < 1) return // 無需載入,跳出迴圈
313
+
314
+ const getPromise = []
315
+ needLoadPages.forEach(page => {
316
+ getPromise.push(axios.get(props.apiUrl, {
317
+ params: {
318
+ userId: props.userId,
319
+ projectId: props.projectId,
320
+ onlyMyFile: onlyMyFile.value,
321
+ page: page,
322
+ limit: repositoryFilesPagination.value.size
323
+ }
324
+ }))
325
+ })
326
+
327
+ const pageResults = await Promise.allSettled(getPromise)
328
+ pageResults.forEach((result) => {
329
+ if (result.status === 'rejected') {
330
+ throw new Error('載入檔案列表失敗')
331
+ }
332
+ if (!result.value?.headers?.["x-pagination"]) {
333
+ throw new Error('分頁資訊遺失')
334
+ }
335
+ if (result.status === 'fulfilled') {
336
+ const resPagination = JSON.parse(result.value.headers["x-pagination"])
337
+ repositoryFiles.value = repositoryFiles.value.filter(pg => pg.page !== resPagination.CurrentPage) // 移除重複頁數
338
+ repositoryFiles.value.push({ data: result.value.data, page: resPagination.CurrentPage });
339
+ repositoryFilesPagination.value.totalPages = resPagination.TotalPages;
340
+ }
341
+ });
342
+
343
+ const nextPage = repositoryFilesPagination.value.pagesToLoad.size > 0
344
+ ? Math.max(...repositoryFilesPagination.value.pagesToLoad) + 1
345
+ : 1
346
+ if (repositoryFilesPagination.value.totalPages && nextPage > repositoryFilesPagination.value.totalPages) return // 已達最後頁,跳出迴圈
347
+ repositoryFilesPagination.value.pagesToLoad.add(nextPage)
348
+ await nextTick()
349
+ checkNeedLoadMore()
350
+ } catch (error) {
351
+ console.error(error)
352
+ } finally {
353
+ isLoading.value = false
354
+ }
355
+ }
356
+ const reloadRepositoryFiles = debounce(async () => {
357
+ repositoryFilesPagination.value = {
358
+ pagesToLoad: new Set([1]),
359
+ totalPages: undefined,
360
+ size: 20
361
+ }
362
+ repositoryFiles.value = []
363
+ await nextTick()
364
+ await getRepositoryFiles()
365
+ }, 200)
366
+
367
+
368
+ // 選取檔案
369
+ const selectedFilesSet = ref(new Set())
370
+ const selectMultimedia = (multimediaUid) => {
371
+ if (multimediaUid === undefined || multimediaUid === null) return
372
+ if (selectedFilesSet.value.has(multimediaUid)) {
373
+ selectedFilesSet.value.delete(multimediaUid)
374
+ return
375
+ }
376
+ selectedFilesSet.value.add(multimediaUid)
377
+ }
378
+ const clearSelected = () => {
379
+ selectedFilesSet.value.clear()
380
+ }
381
+
382
+ const submitSelected = () => {
383
+ let res = repositoryFiles.value.flatMap(pageGroups => pageGroups.data).filter(item => selectedFilesSet.value.has(item[optionsAdjust.value.bindingKey])) || []
384
+ res = res.reduce((acc, item) => {
385
+ acc.push(toRaw(item))
386
+ return acc
387
+ }, [])
388
+ emit('submitSelected', res)
389
+ modalHide(modalDom.value)
390
+ }
391
+ onMounted(() => {
392
+ onlyMyFile.value = optionsAdjust.value.onlyMyFileDefaultValue
393
+ })
394
+ defineExpose({
395
+ show,
396
+ onlyMyFile
397
+ })
398
+ </script>
399
+ <!-- styleCss請自行在 js 或 scss 引入
400
+ @import "../scss/Helpers/_ModalFileRepository.scss"; -->
package/README.md CHANGED
@@ -380,6 +380,31 @@ import OOXX from '../../node_modules/jobdone-shared-files/OOXX.vue';
380
380
  | --------- | ------------------ |
381
381
  | select | 選到項目要做什麼 |
382
382
 
383
+ ## 09.ModalFileRepositorySelector
384
+
385
+ ### 參數
386
+ |參數|型別|說明|必要|
387
+ |--|--|--|--|
388
+ | `project-id` | `String` | 專案ID | 是 |
389
+ | `user-id` | `String` | 使用者ID | 是 |
390
+ | `api-url` | `String` | 取得檔案列表的 API URL | 是 |
391
+ | `options` | `Object` | 額外參數設定,詳細參考下方| 否 |
392
+
393
+ ### options 額外參數設定
394
+ |參數|型別|預設|可用選項|說明|
395
+ |--|--|--|--|--|
396
+ | `subModal` | `Boolean` | `false` | `true` `false` | 是否為子視窗。子視窗會取消 backdrop-filter 效果,且高度略低於一般Modal
397
+ | `modalSize` | `String` | `lg` | `sm` `md` `lg` `xl` | Modal的尺寸
398
+ | `modalTitle` | `String` | `暫存媒體選擇器` | | Modal的標題
399
+ | `modalIcon` | `String` | `inventory_2` | | Modal的圖示(MaterialIcons)
400
+ | `onlyMyFileCanSwitch` | `Boolean` | `true` | `true` `false` | 是否顯示「僅顯示我的檔案」切換選項
401
+ | `onlyMyFileDefaultValue` | `Boolean` | `true` | `true` `false` | 「僅顯示我的檔案」預設選項
402
+ | `bindingKey` | `String` | `uid` | | 選項唯一值的keyName
403
+ | `createAtKey` | `String` | `createAt` | | 檔案建立時間的keyName
404
+ | `fileUrlKey` | `String` | `fileUrl` | | 檔案URL的keyName
405
+ | `thumbnailUrlKey` | `String` | `thumbnailUrl` | | 檔案縮圖URL的keyName
406
+
407
+
383
408
  ### .
384
409
  ### .
385
410
  ### .
@@ -0,0 +1,56 @@
1
+ import Modal from 'bootstrap/js/dist/modal.js';
2
+
3
+ /** 移除焦點Function */
4
+ const blurHandler = () => {
5
+ document.activeElement && document.activeElement.blur();
6
+ };
7
+
8
+ /** 取得與 DOM 元素關聯的Modal實例,或在未初始化的情況下建立新的Modal實例。
9
+ * @param { HTMLElement } modalDom 光箱的DOM
10
+ */
11
+ export const getOrCreateInstance = (modalDom) => {
12
+ return Modal.getOrCreateInstance(modalDom);
13
+ }
14
+
15
+ /**
16
+ * 開啟光箱
17
+ * @param { HTMLElement } modalDom 光箱的DOM
18
+ * @param { Function } modalHiddenFun 關閉光箱後的回調函數(等待 CSS 轉換完成後執行)
19
+ * @param { Boolean } hidePrevented static = true 時,使用者點選黑色遮罩被阻止關閉光箱時的回調函數
20
+ */
21
+ export const modalShow = (modalDom, modalHiddenFun, hidePreventedFun) => {
22
+ const removeEventListeners = () => {
23
+ modalDom.removeEventListener('hide.bs.modal', blurHandler);
24
+ modalDom.removeEventListener('hidden.bs.modal', afterCloseModalFun);
25
+ modalDom.removeEventListener('hidePrevented.bs.modal', hidePreventedFun);
26
+ }
27
+
28
+ const afterCloseModalFun = () => {
29
+ removeEventListeners();
30
+ if (modalHiddenFun && typeof modalHiddenFun === 'function') {
31
+ modalHiddenFun();
32
+ }
33
+ };
34
+
35
+ // 先移除事件監聽,避免重複添加
36
+ removeEventListeners();
37
+
38
+ modalDom.addEventListener('hide.bs.modal', blurHandler); // 關閉光箱時需移除焦點,避免aria-hidden="true"的元素仍然有焦點
39
+ modalDom.addEventListener('hidden.bs.modal', afterCloseModalFun);
40
+ if (hidePreventedFun && typeof hidePreventedFun === 'function') {
41
+ modalDom.addEventListener('hidePrevented.bs.modal', hidePreventedFun);
42
+ }
43
+
44
+ getOrCreateInstance(modalDom).show();
45
+ };
46
+
47
+ /**
48
+ * 手動關閉光箱
49
+ * @param { HTMLElement } modalDom 光箱的DOM
50
+ */
51
+ export const modalHide = (modalDom, dispose = false) => {
52
+ getOrCreateInstance(modalDom).hide();
53
+ if (dispose) {
54
+ getOrCreateInstance(modalDom).dispose();
55
+ }
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jobdone-shared-files",
3
- "version": "1.1.12",
3
+ "version": "1.1.14",
4
4
  "description": "Shared JS and SCSS for Jobdone Enterprise.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,365 @@
1
+ #ModalFileRepository {
2
+ &.modal.sub-modal {
3
+ backdrop-filter: none;
4
+ background-color: rgba(#000000, 0.1);
5
+
6
+
7
+ .modal-content {
8
+ box-shadow: var(--bs-box-shadow-lg);
9
+ }
10
+
11
+ >.modal-dialog:not(.modal-fullscreen) {
12
+ max-height: calc(100% - 100px);
13
+ margin-top: 60px;
14
+ margin-bottom: 40px;
15
+
16
+ @each $key,
17
+ $breakpoint in $grid-breakpoints {
18
+ @media (max-width: $breakpoint) {
19
+ &.modal-fullscreen-#{$key}-down {
20
+ max-height: 100%;
21
+ margin-top: 0;
22
+ margin-bottom: 0;
23
+ }
24
+ }
25
+
26
+ }
27
+ }
28
+ }
29
+
30
+ .the-month-content {
31
+ margin-bottom: 2rem;
32
+
33
+
34
+ .the-month-content-header {
35
+ font-size: 1.25rem;
36
+ border-bottom: 1px solid var(--gray-300);
37
+ padding-top: .75rem;
38
+ padding-bottom: .5rem;
39
+ margin-bottom: .5rem;
40
+ position: sticky;
41
+ top: 0;
42
+ backdrop-filter: blur(2px);
43
+ background-color: rgba(white, .8);
44
+ z-index: 10;
45
+ }
46
+
47
+ .the-month-content-body {
48
+ display: grid;
49
+ gap: .25rem;
50
+ grid-template-columns: repeat(auto-fill, minmax(8.6rem, 1fr));
51
+ }
52
+
53
+ .the-date-content {
54
+ display: flex;
55
+ justify-content: flex-end;
56
+ flex-direction: column;
57
+ position: relative;
58
+
59
+ .the-date-content-header {
60
+ margin-bottom: 0.25rem;
61
+ font-size: 0.875rem;
62
+ display: grid;
63
+ grid-template-columns: 3px auto 1fr;
64
+ gap: 0.5rem;
65
+ line-height: 1.25rem;
66
+ color: var(--gray-800);
67
+ padding-top: 0.5rem;
68
+ padding-bottom: .5rem;
69
+ margin-bottom: auto;
70
+
71
+ &::before {
72
+ display: block;
73
+ content: " ";
74
+ width: 3px;
75
+ background-color: var(--bs-primary);
76
+ border-radius: 99px;
77
+ }
78
+ }
79
+
80
+ .the-date-content-body {
81
+ position: relative;
82
+ padding: 0;
83
+ transition: .1s ease-out;
84
+
85
+ &:has(.is-selected) {
86
+ border: 3px solid rgba(var(--bs-primary-rgb), 0.8);
87
+ border-radius: calc(var(--bs-border-radius) + 3px);
88
+ background-color: #fff;
89
+ transform: scale(.95);
90
+
91
+ .multimedia-preview-content {
92
+ transform: scale(.95);
93
+ }
94
+ }
95
+ }
96
+
97
+ .the-date-content-footer {
98
+ opacity: 0;
99
+ position: absolute;
100
+ transition: .3s ease-out;
101
+ bottom: 0;
102
+ z-index: 10;
103
+ padding: 0.5rem;
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: flex-end;
107
+ width: 100%;
108
+ gap: .5rem;
109
+ background: linear-gradient(#00000000, #000000b0);
110
+ border-bottom-left-radius: var(--bs-border-radius);
111
+ border-bottom-right-radius: var(--bs-border-radius);
112
+
113
+ .save-file,
114
+ .more-info {
115
+ width: 1.5rem;
116
+ height: 1.5rem;
117
+ background-color: transparent;
118
+ z-index: 10;
119
+ margin: 0;
120
+ font-size: 0.5rem;
121
+ display: flex;
122
+ border: none;
123
+ align-items: center;
124
+ justify-content: center;
125
+ color: #fff;
126
+ transition: 0.3s ease-out;
127
+
128
+ &::before {
129
+ transition: 0.3s ease-out;
130
+ font-size: 1.25rem;
131
+ }
132
+
133
+ &:hover::before {
134
+ color: var(--blue-500);
135
+ }
136
+ }
137
+
138
+ .save-file {
139
+ &::before {
140
+ content: "file_download";
141
+ font-family: "Material Icons";
142
+ }
143
+
144
+ &:hover {
145
+ cursor: pointer;
146
+ }
147
+ }
148
+
149
+ .more-info {
150
+ &::before {
151
+ content: "info";
152
+ font-family: "Material Icons";
153
+ }
154
+
155
+ &:hover {
156
+ cursor: pointer;
157
+ }
158
+
159
+ }
160
+ }
161
+
162
+ &:hover,
163
+ &.batch-operating {
164
+
165
+ .the-date-content-footer,
166
+ .select-control {
167
+ opacity: 1;
168
+ }
169
+
170
+ .multimedia-preview-content.is-video::before {
171
+ transform: scale(1.1);
172
+ }
173
+ }
174
+
175
+ &:hover {
176
+ cursor: pointer;
177
+
178
+ img {
179
+ transform: scale(1.05);
180
+ }
181
+ }
182
+ }
183
+
184
+ .multimedia-select-content {
185
+ position: absolute;
186
+ width: 100%;
187
+ width: -webkit-fill-available;
188
+
189
+ .select-control {
190
+ border: 0;
191
+ background-color: transparent;
192
+ top: 0;
193
+ right: 0;
194
+ padding: 0.5rem;
195
+ z-index: 10;
196
+ margin: 0;
197
+ position: absolute;
198
+ user-select: none;
199
+ // opacity: 0;
200
+ transition: opacity 0.2s ease-out;
201
+
202
+ &::before {
203
+ content: "check";
204
+ display: block;
205
+ position: absolute;
206
+ margin: auto;
207
+ color: #fff;
208
+ font-size: 1rem;
209
+ line-height: 1rem;
210
+ opacity: .5;
211
+ font-family: "Material Icons";
212
+ z-index: 10;
213
+ width: 1rem;
214
+ height: 1rem;
215
+ left: calc(50% - 0.5rem);
216
+ top: calc(50% - 0.5rem);
217
+ transform: translateY(-1px);
218
+ transition: opacity 0.2s ease-out;
219
+ }
220
+
221
+ &::after {
222
+ opacity: .8;
223
+ border: 2px solid;
224
+ border-color: #ffffff85;
225
+ content: " ";
226
+ display: block;
227
+ width: 1.75rem;
228
+ height: 1.75rem;
229
+ background-color: rgba(#000, 0.2);
230
+ border-radius: var(--bs-border-radius);
231
+ backdrop-filter: blur(2px);
232
+ transition: 0.2s ease-out;
233
+ }
234
+
235
+ &:hover {
236
+ cursor: pointer;
237
+
238
+ &::before {
239
+ opacity: 1;
240
+ }
241
+
242
+ &::after {
243
+ // background-color: rgba(100, 122, 241, 0);
244
+ }
245
+ }
246
+
247
+ &.is-selected {
248
+ opacity: 1;
249
+
250
+ &::before {
251
+ opacity: 1;
252
+ }
253
+
254
+ &::after {
255
+ opacity: 1;
256
+ background-color: var(--bs-primary);
257
+ border-color: #fff;
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ .multimedia-preview-content {
264
+ position: relative;
265
+ display: block;
266
+ border: 0;
267
+ background-color: transparent;
268
+ width: 100%;
269
+ padding-top: 100%;
270
+ overflow: hidden;
271
+ background-color: var(--purple-100);
272
+ border-radius: var(--bs-border-radius);
273
+
274
+ img {
275
+ transition: 0.3s ease-out;
276
+ border-radius: var(--bs-border-radius);
277
+ width: 100%;
278
+ height: 100%;
279
+ object-fit: cover;
280
+ position: absolute;
281
+ top: 0;
282
+ left: 0;
283
+ /* 確保圖片覆蓋整個方形區域 */
284
+ }
285
+
286
+
287
+
288
+ &.is-video::before {
289
+ position: absolute;
290
+ transition: 0.3s ease-out;
291
+ background-color: rgba(#000, 0.4);
292
+ color: #fff;
293
+ backdrop-filter: blur(2px);
294
+ display: flex;
295
+ justify-content: center;
296
+ align-items: center;
297
+ width: 2.8rem;
298
+ height: 2.8rem;
299
+ top: calc(50% - 1.4rem);
300
+ left: calc(50% - 1.4rem);
301
+ box-shadow: 0 0px 12px 4px #c3c3c396;
302
+ z-index: 100;
303
+ font-family: "Material Icons";
304
+ content: "play_arrow";
305
+ font-size: 1.5rem;
306
+ border-radius: 99px;
307
+ }
308
+ }
309
+
310
+
311
+ }
312
+
313
+ .is-unselectable {
314
+ user-select: none;
315
+ }
316
+
317
+ .top-header {
318
+ display: flex;
319
+ align-items: center;
320
+ border-bottom: 1px solid var(--gray-300);
321
+ background-color: var(--gray-100);
322
+
323
+ >* {
324
+ padding: .5rem 1rem;
325
+ }
326
+
327
+ p {
328
+ color: var(--gray-500);
329
+ margin-bottom: 0;
330
+ margin-right: auto;
331
+ }
332
+ }
333
+
334
+ .the-date-content-multiple-tag {
335
+ position: absolute;
336
+ z-index: 2;
337
+ top: 0;
338
+ left: 0;
339
+ border: none;
340
+ background-color: transparent;
341
+ padding: 0;
342
+ margin: 0;
343
+
344
+
345
+
346
+ &::before {
347
+ height: 1.875rem;
348
+ width: 2rem;
349
+ content: "insert_link";
350
+ font-family: "Material Icons";
351
+ font-size: 1rem;
352
+ line-height: 1rem;
353
+ display: flex;
354
+ align-items: center;
355
+ justify-content: center;
356
+ background-color: rgba(var(--green-500-rgb), .9);
357
+ color: #fff;
358
+ border-top-left-radius: var(--bs-border-radius);
359
+ border-bottom-right-radius: var(--bs-border-radius);
360
+ top: 0;
361
+ left: 0;
362
+ transition: .1s ease-out;
363
+ }
364
+ }
365
+ }