itube-specs 0.0.1061 → 0.0.1063

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.
package/SITE_UPDATES.md CHANGED
@@ -11,6 +11,13 @@
11
11
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
12
12
  кому именно (всем / сайтам с N), что будет, если не сделать.
13
13
 
14
+ ## 0.0.1062 — 2026-11-02
15
+
16
+ - **Всем — к сведению:** за тем же флагом `FeatureUploadEnabled` — страница «My videos» `/library/uploads` (загруженные
17
+ ролики пользователя: вкладки по статусу, видимость, ссылка, правка названия, удаление; данные — заглушка). Туда
18
+ ведёт «Go to my videos» после загрузки, а в `/library` появилась секция «My videos» (4 последних ролика, «Show all»). Новые общие ключи `my_uploads` и три ключа в `snackbar` (пока только en-US).
19
+ Делать ничего не надо.
20
+
14
21
  ## 0.0.1061 — 2026-11-02
15
22
 
16
23
  - **Всем — решить флаг:** новая страница загрузки видео `/upload` (выбор файла с проверкой в браузере → детали
@@ -467,7 +467,9 @@ $header-language-menu-shadow: 0 20px 25px -5px #{$mono-black-25}, 0 8px 10px -6p
467
467
  $header-language-menu-item--hover: $color-zinc-800-50;
468
468
 
469
469
  // ─── notifications ───
470
- $notifications-icon: $color-zinc-300;
470
+ $notifications-icon: $color-zinc-400;
471
+ $notifications-bg: $color-zinc-800-50;
472
+ $notifications-bg--hover: $color-zinc-700;
471
473
  $notifications-icon--mobile: $color-zinc-200;
472
474
  $notifications-icon--hover: $color-white;
473
475
  $notifications-badge-bg: $color-indigo-600;
@@ -1270,3 +1272,31 @@ $upload-radio-ring: $color-zinc-700;
1270
1272
  $upload-radio-dot: $color-white;
1271
1273
  $upload-rules-text: $color-zinc-400;
1272
1274
  $upload-rules-dot: $color-zinc-600;
1275
+
1276
+ // ─── My videos (library uploads) ───
1277
+ $library-upload-placeholder-from: $color-zinc-700;
1278
+ $library-upload-placeholder-to: $color-zinc-800;
1279
+ $library-upload-badge-bg: $color-black-75;
1280
+ $library-upload-badge-text: $color-white;
1281
+ $library-upload-dot: $color-indigo-400;
1282
+ $library-upload-dot--error: $color-red-500;
1283
+ $library-upload-title: $color-white;
1284
+ $library-upload-title--hover: $color-indigo-400;
1285
+ $library-upload-untitled: $color-zinc-500;
1286
+ $library-upload-meta: $color-zinc-500;
1287
+ $library-upload-meta--error: $color-red-400;
1288
+ $library-upload-menu-icon: $color-zinc-500;
1289
+ $library-upload-menu-icon--hover: $color-white;
1290
+ $library-upload-menu-value: $color-zinc-500;
1291
+ $library-upload-delete-text: $color-red-400;
1292
+ $library-upload-radio: $color-zinc-500;
1293
+ $library-upload-radio--checked: $color-indigo-600;
1294
+ $library-upload-radio-dot: $color-white;
1295
+ $library-upload-empty-icon-bg: $color-zinc-900;
1296
+ $library-upload-empty-icon-border: $color-zinc-800;
1297
+ $library-upload-empty-icon: $color-zinc-400;
1298
+ $library-upload-empty-title: $color-white;
1299
+ $library-upload-note: $color-zinc-500;
1300
+ $library-upload-tab-count: $color-zinc-500;
1301
+ $library-upload-tab-count--active: $color-white-70;
1302
+ $library-upload-fix-text: $color-zinc-400;
@@ -523,7 +523,9 @@ $header-language-menu-bg: $color-slate-850;
523
523
  $header-language-menu-border: $color-white-10;
524
524
  $header-language-menu-shadow: 0 20px 25px -5px #{$mono-black-25}, 0 8px 10px -6px #{$mono-black-25};
525
525
  $header-language-menu-item--hover: $color-white-5;
526
- $notifications-icon: $color-gray-300;
526
+ $notifications-icon: $color-gray-400;
527
+ $notifications-bg: $color-slate-750;
528
+ $notifications-bg--hover: $color-slate-700;
527
529
  $notifications-icon--mobile: $color-gray-200;
528
530
  $notifications-icon--hover: $color-white;
529
531
  $notifications-badge-bg: $color-indigo-600;
@@ -1299,3 +1301,31 @@ $upload-radio-ring: $color-gray-300;
1299
1301
  $upload-radio-dot: $color-white;
1300
1302
  $upload-rules-text: $color-gray-300;
1301
1303
  $upload-rules-dot: $color-gray-500;
1304
+
1305
+ // ─── My videos (library uploads) ───
1306
+ $library-upload-placeholder-from: $color-gray-200;
1307
+ $library-upload-placeholder-to: $color-gray-300;
1308
+ $library-upload-badge-bg: $color-black-75;
1309
+ $library-upload-badge-text: $color-white;
1310
+ $library-upload-dot: $color-indigo-400;
1311
+ $library-upload-dot--error: $color-red-500;
1312
+ $library-upload-title: $color-gray-900;
1313
+ $library-upload-title--hover: $color-indigo-600;
1314
+ $library-upload-untitled: $color-gray-500;
1315
+ $library-upload-meta: $color-gray-500;
1316
+ $library-upload-meta--error: $color-red-600;
1317
+ $library-upload-menu-icon: $color-gray-500;
1318
+ $library-upload-menu-icon--hover: $color-gray-900;
1319
+ $library-upload-menu-value: $color-gray-500;
1320
+ $library-upload-delete-text: $color-red-400;
1321
+ $library-upload-radio: $color-gray-400;
1322
+ $library-upload-radio--checked: $color-indigo-600;
1323
+ $library-upload-radio-dot: $color-white;
1324
+ $library-upload-empty-icon-bg: $color-gray-100;
1325
+ $library-upload-empty-icon-border: $color-gray-200;
1326
+ $library-upload-empty-icon: $color-gray-500;
1327
+ $library-upload-empty-title: $color-gray-900;
1328
+ $library-upload-note: $color-gray-500;
1329
+ $library-upload-tab-count: $color-gray-500;
1330
+ $library-upload-tab-count--active: $color-white-70;
1331
+ $library-upload-fix-text: $color-gray-300;
@@ -22,10 +22,6 @@
22
22
  </div>
23
23
 
24
24
  <div class="layout-header__right">
25
- <LayoutNotifications
26
- v-if="featureFlags.FeatureFollowEnabled"
27
- class="layout-header__notifications"
28
- />
29
25
  <NuxtLink
30
26
  v-if="featureFlags.FeatureUploadEnabled"
31
27
  :to="uploadLink"
@@ -76,6 +72,11 @@
76
72
  </transition>
77
73
  </div>
78
74
 
75
+ <LayoutNotifications
76
+ v-if="featureFlags.FeatureFollowEnabled"
77
+ class="layout-header__notifications"
78
+ />
79
+
79
80
  <LazyLayoutHeaderUser
80
81
  v-if="isAuthorized"
81
82
  class="layout-header__user _from-sd"
@@ -51,8 +51,29 @@ $badge-raise: 6px;
51
51
  color: $notifications-icon--mobile;
52
52
  }
53
53
 
54
- @include hover(color) {
54
+ @include from-br($brkpnt-burger) {
55
+ position: relative;
56
+ justify-content: center;
57
+ width: 36px;
58
+ height: 36px;
59
+ padding: 0;
60
+ background-color: $notifications-bg;
61
+ border-radius: $radius-md;
62
+ }
63
+
64
+ @include hover(color, background-color) {
55
65
  color: $notifications-icon--hover;
66
+
67
+ @include from-br($brkpnt-burger) {
68
+ background-color: $notifications-bg--hover;
69
+ }
70
+ }
71
+ }
72
+
73
+ .layout-notifications__icon {
74
+ @include from-br($brkpnt-burger) {
75
+ width: 20px;
76
+ height: 20px;
56
77
  }
57
78
  }
58
79
 
@@ -75,5 +96,12 @@ $badge-raise: 6px;
75
96
  background-color: $notifications-badge-bg;
76
97
  border-radius: $radius-full;
77
98
  box-shadow: 0 0 0 2px $notifications-badge-ring;
99
+
100
+ @include from-br($brkpnt-burger) {
101
+ position: absolute;
102
+ top: -6px;
103
+ right: -6px;
104
+ margin: 0;
105
+ }
78
106
  }
79
107
  </style>
@@ -0,0 +1,448 @@
1
+ <!-- Card of the user's uploaded video in «My videos»: cover (placeholder until AI picks one), status badge, duration and visibility, menu with visibility / copy link / title fix / delete -->
2
+ <template>
3
+ <div class="library-upload-card">
4
+ <component
5
+ :is="link ? NuxtLink : 'div'"
6
+ :to="link"
7
+ class="library-upload-card__poster"
8
+ >
9
+ <UiImg
10
+ v-if="upload.thumbUrls"
11
+ :urls="upload.thumbUrls"
12
+ :alt="name"
13
+ sizes="auto, (min-width: 1440px) 25vw, (min-width: 900px) 33vw, (min-width: 370px) 50vw, 100vw"
14
+ class="library-upload-card__image"
15
+ width="480"
16
+ height="270"
17
+ />
18
+ <span
19
+ v-else
20
+ class="library-upload-card__placeholder"
21
+ aria-hidden="true"
22
+ />
23
+
24
+ <span
25
+ v-if="upload.status !== MyUploadStatus.Published"
26
+ class="library-upload-card__badge --status"
27
+ >
28
+ <span
29
+ :class="{'--error': upload.status === MyUploadStatus.Declined}"
30
+ class="library-upload-card__dot"
31
+ aria-hidden="true"
32
+ />
33
+ {{ t(`my_uploads.status.${upload.status}`) }}
34
+ </span>
35
+
36
+ <span class="library-upload-card__bottom">
37
+ <span class="library-upload-card__badge">{{ getDuration(upload.duration) }}</span>
38
+ <span
39
+ v-if="upload.visibility !== UploadVisibility.Public"
40
+ :title="t(`my_uploads.visibility.${upload.visibility}`)"
41
+ class="library-upload-card__badge --icon"
42
+ >
43
+ <UiIcon
44
+ :name="upload.visibility === UploadVisibility.Private ? 'lock' : 'link'"
45
+ size="12"
46
+ aria-hidden="true"
47
+ />
48
+ <span class="_visually-hidden">{{ t(`my_uploads.visibility.${upload.visibility}`) }}</span>
49
+ </span>
50
+ </span>
51
+ </component>
52
+
53
+ <div class="library-upload-card__info">
54
+ <div class="library-upload-card__head">
55
+ <component
56
+ :is="link ? NuxtLink : 'span'"
57
+ :to="link"
58
+ :class="{'--untitled': !upload.title, '--link': link}"
59
+ class="library-upload-card__title _truncate"
60
+ >{{ name }}</component>
61
+
62
+ <UiDropdown
63
+ ref="dropdownRef"
64
+ class="library-upload-card__menu"
65
+ right
66
+ use-v-if
67
+ >
68
+ <template #trigger>
69
+ <button
70
+ :aria-label="t('my_uploads.menu.label')"
71
+ class="library-upload-card__menu-trigger"
72
+ type="button"
73
+ >
74
+ <UiIcon
75
+ name="dots-vertical"
76
+ size="18"
77
+ />
78
+ </button>
79
+ </template>
80
+ <template #items="className">
81
+ <template v-if="step === 'menu'">
82
+ <button
83
+ :class="[className, {'--disabled': !canChangeVisibility(upload)}]"
84
+ :disabled="!canChangeVisibility(upload)"
85
+ type="button"
86
+ @click.stop="step = 'visibility'"
87
+ >
88
+ <UiIcon
89
+ name="eye"
90
+ size="20"
91
+ />
92
+ <span class="library-upload-card__menu-label">{{ t('my_uploads.menu.visibility') }}</span>
93
+ <span class="library-upload-card__menu-value">{{ t(`upload_page.visibility_options.${upload.visibility}.title`) }}</span>
94
+ <UiIcon
95
+ name="angle-right"
96
+ size="16"
97
+ />
98
+ </button>
99
+ <button
100
+ :class="className"
101
+ type="button"
102
+ @click="emit('copy')"
103
+ >
104
+ <UiIcon
105
+ name="link"
106
+ size="20"
107
+ />
108
+ {{ t('my_uploads.menu.copy_link') }}
109
+ </button>
110
+ <button
111
+ :class="className"
112
+ type="button"
113
+ @click="emit('fix')"
114
+ >
115
+ <UiIcon
116
+ name="pen"
117
+ size="20"
118
+ />
119
+ {{ t('my_uploads.menu.suggest_fix') }}
120
+ </button>
121
+ <button
122
+ :class="[className, 'library-upload-card__delete']"
123
+ type="button"
124
+ @click="emit('delete')"
125
+ >
126
+ <UiIcon
127
+ name="trash-bin"
128
+ size="20"
129
+ />
130
+ {{ t('my_uploads.menu.delete') }}
131
+ </button>
132
+ </template>
133
+
134
+ <template v-else>
135
+ <button
136
+ :class="className"
137
+ type="button"
138
+ @click.stop="step = 'menu'"
139
+ >
140
+ <UiIcon
141
+ name="angle-left"
142
+ size="20"
143
+ />
144
+ {{ t('my_uploads.menu.visibility') }}
145
+ </button>
146
+ <button
147
+ v-for="option in VISIBILITY_OPTIONS"
148
+ :key="option"
149
+ :class="className"
150
+ :aria-checked="option === upload.visibility"
151
+ type="button"
152
+ role="menuitemradio"
153
+ @click="emit('visibility', option)"
154
+ >
155
+ <span
156
+ :class="{'--checked': option === upload.visibility}"
157
+ class="library-upload-card__radio"
158
+ aria-hidden="true"
159
+ />
160
+ <span class="library-upload-card__menu-label">
161
+ {{ t(`upload_page.visibility_options.${option}.title`) }}
162
+ <span class="library-upload-card__menu-hint">{{ t(`upload_page.visibility_options.${option}.text`) }}</span>
163
+ </span>
164
+ </button>
165
+ </template>
166
+ </template>
167
+ </UiDropdown>
168
+ </div>
169
+
170
+ <p class="library-upload-card__meta">
171
+ <UiIcon
172
+ v-if="nicheIcon"
173
+ :name="nicheIcon"
174
+ class="library-upload-card__niche"
175
+ size="14"
176
+ />
177
+ <span
178
+ :class="{'--error': upload.status === MyUploadStatus.Declined}"
179
+ :title="upload.status === MyUploadStatus.Declined ? meta : undefined"
180
+ class="library-upload-card__meta-text _truncate"
181
+ >{{ meta }}</span>
182
+ </p>
183
+ </div>
184
+ </div>
185
+ </template>
186
+
187
+ <script setup lang="ts">
188
+ import { NuxtLink } from '#components';
189
+ import { MyUploadStatus, UploadVisibility, getDuration } from '../../runtime';
190
+ import type { IMyUpload } from '../../types';
191
+
192
+ const VISIBILITY_OPTIONS = Object.values(UploadVisibility);
193
+
194
+ const props = defineProps<{
195
+ upload: IMyUpload
196
+ }>();
197
+
198
+ const emit = defineEmits<{
199
+ (eventName: 'copy' | 'fix' | 'delete'): void
200
+ (eventName: 'visibility', value: UploadVisibility): void
201
+ }>();
202
+
203
+ const { t, n, locale } = useI18n();
204
+ const { generateLink } = useGenerateLink();
205
+ const { niches } = useNiches();
206
+ const { canChangeVisibility } = useMyUploads();
207
+
208
+ const dropdownRef = ref<{ open: boolean }>();
209
+ const step = ref<'menu' | 'visibility'>('menu');
210
+
211
+ watch(() => dropdownRef.value?.open, (open) => {
212
+ if (!open) step.value = 'menu';
213
+ });
214
+
215
+ const name = computed(() => props.upload.title ?? props.upload.fileName);
216
+
217
+ const link = computed(() => props.upload.videoId ? generateLink(`/videos/${props.upload.videoId}`) : undefined);
218
+
219
+ const nicheIcon = computed(() => niches.value.length > 1
220
+ ? niches.value.find(item => item.name === props.upload.niche)?.icon
221
+ : undefined);
222
+
223
+ const meta = computed(() => {
224
+ const { status } = props.upload;
225
+
226
+ if (status === MyUploadStatus.Declined) return props.upload.declineReason ?? t('my_uploads.meta.declined');
227
+ if (status !== MyUploadStatus.Published) return t(`my_uploads.meta.${status}`);
228
+
229
+ return t('my_uploads.meta.published', {
230
+ views: n(props.upload.views ?? 0, 'compact'),
231
+ ago: props.upload.publishedAt ? useFormatTimeAgo(props.upload.publishedAt, locale.value) : '',
232
+ });
233
+ });
234
+ </script>
235
+
236
+ <style lang="scss">
237
+ .library-upload-card {
238
+ @include video-card;
239
+
240
+ row-gap: 12px;
241
+
242
+ @include from-br(sm) {
243
+ row-gap: 16px;
244
+ }
245
+ }
246
+
247
+ .library-upload-card__poster {
248
+ @include video-card-poster;
249
+
250
+ display: block;
251
+ }
252
+
253
+ .library-upload-card__image,
254
+ .library-upload-card__placeholder {
255
+ display: block;
256
+ width: 100%;
257
+ height: 100%;
258
+ transition: transform 0.3s;
259
+ }
260
+
261
+ .library-upload-card__image {
262
+ object-fit: cover;
263
+ }
264
+
265
+ .library-upload-card__placeholder {
266
+ background-image: linear-gradient(to bottom right, $library-upload-placeholder-from, $library-upload-placeholder-to);
267
+ }
268
+
269
+ @media (hover: hover) {
270
+ .library-upload-card:has(.library-upload-card__poster:hover) .library-upload-card__image,
271
+ .library-upload-card:has(.library-upload-card__poster:hover) .library-upload-card__placeholder {
272
+ transform: scale(1.05);
273
+ }
274
+
275
+ .library-upload-card:has(.library-upload-card__poster:hover) .library-upload-card__title.--link {
276
+ color: $library-upload-title--hover;
277
+ }
278
+ }
279
+
280
+ .library-upload-card__badge {
281
+ @include font-xs;
282
+
283
+ display: flex;
284
+ align-items: center;
285
+ gap: 6px;
286
+ padding: 2px 6px;
287
+ font-weight: 500;
288
+ color: $library-upload-badge-text;
289
+ background-color: $library-upload-badge-bg;
290
+ border-radius: $radius-small;
291
+ }
292
+
293
+ .library-upload-card__badge.--status {
294
+ position: absolute;
295
+ top: 8px;
296
+ left: 8px;
297
+ padding: 4px 8px;
298
+ }
299
+
300
+ .library-upload-card__badge.--icon {
301
+ justify-content: center;
302
+ width: 20px;
303
+ height: 20px;
304
+ padding: 0;
305
+ }
306
+
307
+ .library-upload-card__bottom {
308
+ position: absolute;
309
+ bottom: 8px;
310
+ left: 8px;
311
+ display: flex;
312
+ align-items: center;
313
+ gap: 4px;
314
+ }
315
+
316
+ .library-upload-card__dot {
317
+ width: 6px;
318
+ height: 6px;
319
+ background-color: $library-upload-dot;
320
+ border-radius: $radius-full;
321
+ }
322
+
323
+ .library-upload-card__dot.--error {
324
+ background-color: $library-upload-dot--error;
325
+ }
326
+
327
+ .library-upload-card__info {
328
+ display: flex;
329
+ flex-direction: column;
330
+ gap: 6px;
331
+ min-width: 0;
332
+ }
333
+
334
+ .library-upload-card__head {
335
+ display: flex;
336
+ justify-content: space-between;
337
+ align-items: flex-start;
338
+ gap: 8px;
339
+ }
340
+
341
+ .library-upload-card__title {
342
+ @include font-sm;
343
+
344
+ --line-clamp: 2;
345
+
346
+ font-weight: 500;
347
+ color: $library-upload-title;
348
+ transition: color var(--transition);
349
+ }
350
+
351
+ .library-upload-card__title.--link {
352
+ @include hover(color) {
353
+ color: $library-upload-title--hover;
354
+ }
355
+ }
356
+
357
+ .library-upload-card__title.--untitled {
358
+ --word-break: break-all;
359
+
360
+ color: $library-upload-untitled;
361
+ }
362
+
363
+ .library-upload-card__menu {
364
+ flex-shrink: 0;
365
+ }
366
+
367
+ .library-upload-card__menu-trigger {
368
+ display: grid;
369
+ place-content: center;
370
+ margin-top: -2px;
371
+ padding: 2px;
372
+ color: $library-upload-menu-icon;
373
+
374
+ @include hover(color) {
375
+ color: $library-upload-menu-icon--hover;
376
+ }
377
+ }
378
+
379
+ .library-upload-card__menu-label {
380
+ display: flex;
381
+ flex: 1;
382
+ flex-direction: column;
383
+ text-align: left;
384
+ }
385
+
386
+ .library-upload-card__menu-value,
387
+ .library-upload-card__menu-hint {
388
+ @include font-xs;
389
+
390
+ color: $library-upload-menu-value;
391
+ }
392
+
393
+ .library-upload-card .ui-dropdown__item.--disabled {
394
+ opacity: 0.4;
395
+ cursor: not-allowed;
396
+ }
397
+
398
+ .ui-dropdown__menu-items > .library-upload-card__delete.ui-dropdown__item {
399
+ color: $library-upload-delete-text;
400
+ }
401
+
402
+ .ui-dropdown__menu-items > .library-upload-card__delete.ui-dropdown__item .ui-icon {
403
+ color: inherit;
404
+ }
405
+
406
+ .library-upload-card__radio {
407
+ position: relative;
408
+ flex-shrink: 0;
409
+ width: 16px;
410
+ height: 16px;
411
+ border: 1px solid $library-upload-radio;
412
+ border-radius: $radius-full;
413
+ }
414
+
415
+ .library-upload-card__radio.--checked {
416
+ background-color: $library-upload-radio--checked;
417
+ border-color: $library-upload-radio--checked;
418
+ }
419
+
420
+ .library-upload-card__radio.--checked::after {
421
+ position: absolute;
422
+ inset: 0;
423
+ width: 6px;
424
+ height: 6px;
425
+ margin: auto;
426
+ background-color: $library-upload-radio-dot;
427
+ border-radius: $radius-full;
428
+ content: '';
429
+ }
430
+
431
+ .library-upload-card__meta {
432
+ @include font-xs;
433
+
434
+ display: flex;
435
+ align-items: center;
436
+ gap: 6px;
437
+ min-width: 0;
438
+ color: $library-upload-meta;
439
+ }
440
+
441
+ .library-upload-card__niche {
442
+ flex-shrink: 0;
443
+ }
444
+
445
+ .library-upload-card__meta-text.--error {
446
+ color: $library-upload-meta--error;
447
+ }
448
+ </style>
@@ -0,0 +1,145 @@
1
+ <!-- Grid of the user's uploads with the card menu actions: delete confirmation, title-fix popup, copy link, visibility. Used by «My videos» and its section in the library -->
2
+ <template>
3
+ <div class="library-uploads-grid">
4
+ <LibraryUploadCard
5
+ v-for="upload in uploads"
6
+ :key="upload.id"
7
+ :upload="upload"
8
+ @copy="copyLink(upload)"
9
+ @fix="openFix(upload)"
10
+ @delete="deleteTarget = upload"
11
+ @visibility="setVisibility(upload.id, $event)"
12
+ />
13
+
14
+ <UiConfirm
15
+ :model-value="!!deleteTarget"
16
+ :title="t('my_uploads.delete_title')"
17
+ :confirm-text="t('my_uploads.menu.delete')"
18
+ @update:model-value="deleteTarget = null"
19
+ @confirm="confirmDelete"
20
+ >
21
+ <p class="library-uploads-grid__text">{{ t('my_uploads.delete_text') }}</p>
22
+ </UiConfirm>
23
+
24
+ <transition mode="out-in">
25
+ <UiPopup
26
+ v-if="fixTarget"
27
+ :model-value="!!fixTarget"
28
+ class="library-uploads-grid__fix"
29
+ sheet
30
+ @update:model-value="fixTarget = null"
31
+ >
32
+ <template #title>{{ t('my_uploads.fix_title') }}</template>
33
+ <form
34
+ :id="`${id}-fix`"
35
+ class="library-uploads-grid__fix-form"
36
+ @submit.prevent="sendFix"
37
+ >
38
+ <p class="library-uploads-grid__text">{{ t('my_uploads.fix_text') }}</p>
39
+ <UiInput
40
+ v-model="fixTitle"
41
+ :placeholder="t('my_uploads.fix_placeholder')"
42
+ maxlength="100"
43
+ autofocus
44
+ />
45
+ </form>
46
+ <template #footer>
47
+ <div class="library-uploads-grid__fix-buttons">
48
+ <UiBtn
49
+ size="l"
50
+ theme="muted"
51
+ wide
52
+ @click="fixTarget = null"
53
+ >{{ t('cancel') }}
54
+ </UiBtn>
55
+ <UiBtn
56
+ :disabled="!fixTitle.trim()"
57
+ :form="`${id}-fix`"
58
+ size="l"
59
+ theme="primary"
60
+ type="submit"
61
+ wide
62
+ >{{ t('my_uploads.send') }}
63
+ </UiBtn>
64
+ </div>
65
+ </template>
66
+ </UiPopup>
67
+ </transition>
68
+ </div>
69
+ </template>
70
+
71
+ <script setup lang="ts">
72
+ import type { IMyUpload } from '../../types';
73
+
74
+ defineProps<{
75
+ uploads: IMyUpload[]
76
+ }>();
77
+
78
+ const { t } = useI18n();
79
+ const { generateLink } = useGenerateLink();
80
+ const { show, showError } = useSnackbar();
81
+ const { setVisibility, remove } = useMyUploads();
82
+ const id = useId();
83
+
84
+ const deleteTarget = ref<IMyUpload | null>(null);
85
+ const fixTarget = ref<IMyUpload | null>(null);
86
+ const fixTitle = ref('');
87
+
88
+ async function copyLink(upload: IMyUpload) {
89
+ const path = generateLink(upload.videoId ? `/videos/${upload.videoId}` : '/library/uploads');
90
+
91
+ try {
92
+ await navigator.clipboard.writeText(`${window.location.origin}${path}`);
93
+ show('link_copied');
94
+ } catch {
95
+ showError('link_not_copied');
96
+ }
97
+ }
98
+
99
+ function openFix(upload: IMyUpload) {
100
+ fixTitle.value = upload.title ?? '';
101
+ fixTarget.value = upload;
102
+ }
103
+
104
+ function sendFix() {
105
+ if (!fixTitle.value.trim()) return;
106
+
107
+ fixTarget.value = null;
108
+ show('title_fix_sent');
109
+ }
110
+
111
+ function confirmDelete() {
112
+ if (deleteTarget.value) remove(deleteTarget.value.id);
113
+ deleteTarget.value = null;
114
+ show('video_deleted');
115
+ }
116
+ </script>
117
+
118
+ <style lang="scss">
119
+ .library-uploads-grid {
120
+ @include video-card-grid;
121
+ }
122
+
123
+ .library-uploads-grid__text {
124
+ @include font-sm;
125
+
126
+ color: $library-upload-fix-text;
127
+ }
128
+
129
+ .library-uploads-grid__fix .ui-popup__content {
130
+ padding: 0;
131
+ }
132
+
133
+ .library-uploads-grid__fix-form {
134
+ display: flex;
135
+ flex-direction: column;
136
+ gap: 16px;
137
+ padding: 24px;
138
+ }
139
+
140
+ .library-uploads-grid__fix-buttons {
141
+ display: grid;
142
+ grid-template-columns: 1fr 1fr;
143
+ gap: 12px;
144
+ }
145
+ </style>
@@ -0,0 +1,146 @@
1
+ <!-- «My videos»: status tabs with counts and the grid of the user's uploads. Data is a stub (useMyUploads) -->
2
+ <template>
3
+ <div class="library-uploads">
4
+ <h1 class="_title library-uploads__title">{{ t('my_uploads.title') }}</h1>
5
+
6
+ <div
7
+ v-if="!uploads.length"
8
+ class="library-uploads__empty"
9
+ >
10
+ <span
11
+ class="library-uploads__empty-icon"
12
+ aria-hidden="true"
13
+ >
14
+ <UiIcon
15
+ name="upload-arrow"
16
+ size="24"
17
+ />
18
+ </span>
19
+ <p class="library-uploads__empty-title">{{ t('my_uploads.empty') }}</p>
20
+ <UiLink
21
+ :to="generateLink('/upload')"
22
+ size="l"
23
+ theme="primary"
24
+ >{{ t('my_uploads.upload_first') }}
25
+ </UiLink>
26
+ </div>
27
+
28
+ <template v-else>
29
+ <div
30
+ :aria-label="t('my_uploads.filter_label')"
31
+ class="library-uploads__tabs"
32
+ role="tablist"
33
+ >
34
+ <UiBtn
35
+ v-for="tab in tabs"
36
+ :key="tab.id"
37
+ :active="tab.id === activeTab"
38
+ :aria-selected="tab.id === activeTab"
39
+ class="library-uploads__tab"
40
+ role="tab"
41
+ theme="secondary"
42
+ @click="activeTab = tab.id"
43
+ >
44
+ {{ t(`my_uploads.tabs.${tab.id}`) }}
45
+ <span class="library-uploads__tab-count">{{ tab.count }}</span>
46
+ </UiBtn>
47
+ </div>
48
+
49
+ <LibraryUploadsGrid
50
+ v-if="filtered.length"
51
+ :uploads="filtered"
52
+ />
53
+ <p
54
+ v-else
55
+ class="library-uploads__note"
56
+ >{{ t('my_uploads.no_status') }}</p>
57
+ </template>
58
+
59
+ </div>
60
+ </template>
61
+
62
+ <script setup lang="ts">
63
+ import { MyUploadStatus } from '../../runtime';
64
+ import type { IMyUpload } from '../../types';
65
+
66
+ const TABS = [
67
+ { id: 'all', match: () => true },
68
+ { id: 'published', match: (upload: IMyUpload) => upload.status === MyUploadStatus.Published },
69
+ { id: 'processing', match: (upload: IMyUpload) => upload.status === MyUploadStatus.Processing || upload.status === MyUploadStatus.Review },
70
+ { id: 'declined', match: (upload: IMyUpload) => upload.status === MyUploadStatus.Declined },
71
+ ] as const;
72
+
73
+ type TabId = typeof TABS[number]['id'];
74
+
75
+ const { t } = useI18n();
76
+ const { generateLink } = useGenerateLink();
77
+ const { uploads } = useMyUploads();
78
+
79
+ const activeTab = ref<TabId>('all');
80
+
81
+ const tabs = computed(() => TABS.map(tab => ({ id: tab.id, count: uploads.value.filter(tab.match).length })));
82
+
83
+ const filtered = computed(() => {
84
+ const tab = TABS.find(item => item.id === activeTab.value) ?? TABS[0];
85
+
86
+ return uploads.value.filter(tab.match);
87
+ });
88
+ </script>
89
+
90
+ <style lang="scss">
91
+ .library-uploads__title {
92
+ margin-bottom: 16px;
93
+ }
94
+
95
+ .library-uploads__tabs {
96
+ display: flex;
97
+ flex-wrap: wrap;
98
+ align-items: center;
99
+ gap: 8px;
100
+ margin-bottom: 32px;
101
+ }
102
+
103
+ .library-uploads__tab-count {
104
+ color: $library-upload-tab-count;
105
+ }
106
+
107
+ .library-uploads__tab.--active .library-uploads__tab-count {
108
+ color: $library-upload-tab-count--active;
109
+ }
110
+
111
+ .library-uploads__note {
112
+ @include font-sm;
113
+
114
+ padding-block: 64px;
115
+ text-align: center;
116
+ color: $library-upload-note;
117
+ }
118
+
119
+ .library-uploads__empty {
120
+ display: flex;
121
+ flex-direction: column;
122
+ justify-content: center;
123
+ align-items: center;
124
+ gap: 16px;
125
+ padding-block: 96px;
126
+ text-align: center;
127
+ }
128
+
129
+ .library-uploads__empty-icon {
130
+ display: grid;
131
+ place-content: center;
132
+ width: 56px;
133
+ height: 56px;
134
+ color: $library-upload-empty-icon;
135
+ background-color: $library-upload-empty-icon-bg;
136
+ border: 1px solid $library-upload-empty-icon-border;
137
+ border-radius: $radius-2xl;
138
+ }
139
+
140
+ .library-uploads__empty-title {
141
+ @include font-lg;
142
+
143
+ font-weight: 600;
144
+ color: $library-upload-empty-title;
145
+ }
146
+ </style>
@@ -71,7 +71,7 @@
71
71
 
72
72
  <div class="upload-sent__buttons">
73
73
  <UiLink
74
- :to="generateLink('/library')"
74
+ :to="generateLink('/library/uploads')"
75
75
  size="l"
76
76
  theme="primary"
77
77
  >{{ t('upload_page.go_to_my_videos') }}
@@ -0,0 +1,26 @@
1
+ import { MyUploadStatus } from '../runtime';
2
+ import type { UploadVisibility } from '../runtime';
3
+ import type { IMyUpload } from '../types';
4
+ import { getUploadsStub } from '../stubs/uploads';
5
+
6
+ /**
7
+ * The user's uploaded videos («My videos»). No API yet — a stub list kept in state, so visibility changes and
8
+ * deletions live until reload. Swap the stub and the two mutations for requests when the endpoints land.
9
+ */
10
+ export function useMyUploads() {
11
+ const uploads = useState<IMyUpload[]>('my-uploads', () => getUploadsStub());
12
+
13
+ function canChangeVisibility(upload: IMyUpload) {
14
+ return upload.status !== MyUploadStatus.Processing && upload.status !== MyUploadStatus.Declined;
15
+ }
16
+
17
+ function setVisibility(id: string, visibility: UploadVisibility) {
18
+ uploads.value = uploads.value.map(upload => upload.id === id ? { ...upload, visibility } : upload);
19
+ }
20
+
21
+ function remove(id: string) {
22
+ uploads.value = uploads.value.filter(upload => upload.id !== id);
23
+ }
24
+
25
+ return { uploads, canChangeVisibility, setVisibility, remove };
26
+ }
@@ -1,4 +1,45 @@
1
1
  {
2
+ "my_uploads": {
3
+ "title": "My videos",
4
+ "filter_label": "Filter by status",
5
+ "tabs": {
6
+ "all": "All",
7
+ "published": "Published",
8
+ "processing": "Processing",
9
+ "declined": "Not approved"
10
+ },
11
+ "status": {
12
+ "processing": "Processing",
13
+ "review": "In review",
14
+ "declined": "Not approved"
15
+ },
16
+ "meta": {
17
+ "processing": "AI processing · up to 30 min",
18
+ "review": "In review · up to 24 h",
19
+ "declined": "Not approved",
20
+ "published": "{views} · {ago}"
21
+ },
22
+ "visibility": {
23
+ "unlisted": "Unlisted",
24
+ "private": "Private"
25
+ },
26
+ "menu": {
27
+ "label": "Video options",
28
+ "visibility": "Visibility",
29
+ "copy_link": "Copy link",
30
+ "suggest_fix": "Suggest a title fix",
31
+ "delete": "Delete"
32
+ },
33
+ "delete_title": "Delete this video?",
34
+ "delete_text": "It will be removed from the site. This can't be undone.",
35
+ "fix_title": "Suggest a title fix",
36
+ "fix_text": "Our team will review it before it goes live.",
37
+ "fix_placeholder": "New title",
38
+ "send": "Send",
39
+ "empty": "No videos yet",
40
+ "upload_first": "Upload your first video",
41
+ "no_status": "No videos with this status"
42
+ },
2
43
  "upload_page": {
3
44
  "title_pick": "Upload video",
4
45
  "title_details": "Video details",
@@ -476,6 +517,9 @@
476
517
  "hide_description": "Hide description",
477
518
  "advertising_banner": "Advertising",
478
519
  "snackbar": {
520
+ "link_copied": "Link copied",
521
+ "link_not_copied": "Couldn't copy the link",
522
+ "title_fix_sent": "Thanks, we'll review your title fix",
479
523
  "added": "Added:",
480
524
  "removed": "Removed:",
481
525
  "added_to": "Added to:",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1061",
4
+ "version": "0.0.1063",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -56,6 +56,33 @@
56
56
  </div>
57
57
  </section>
58
58
 
59
+ <section
60
+ v-if="myUploads.length > 0"
61
+ class="library__section"
62
+ >
63
+ <header class="library__section-header">
64
+ <UiSectionTitle
65
+ :title="t('my_uploads.title')"
66
+ icon="upload-arrow"
67
+ title-tag="h2"
68
+ variant="section"
69
+ />
70
+
71
+ <UiLink
72
+ :to="generateLink('/library/uploads')"
73
+ theme="simple"
74
+ >
75
+ {{ t('show_all') }}
76
+ <UiIcon
77
+ name="arrow-right"
78
+ size="16"
79
+ />
80
+ </UiLink>
81
+ </header>
82
+
83
+ <LibraryUploadsGrid :uploads="myUploads"/>
84
+ </section>
85
+
59
86
  <section class="library__section">
60
87
  <header class="library__section-header">
61
88
  <UiSectionTitle
@@ -153,6 +180,7 @@ type LibraryLink = {
153
180
  };
154
181
 
155
182
  const CONTINUE_LIMIT = 4;
183
+ const MY_UPLOADS_LIMIT = 4;
156
184
 
157
185
  /** Сколько аватарок помещается в ряд «что внутри» — остальные уходят в плашку «+N». */
158
186
  const AVATARS_LIMIT = 4;
@@ -162,6 +190,9 @@ const featureFlags = useRuntimeConfig().public.featureFlags;
162
190
 
163
191
  const { generateLink } = useGenerateLink();
164
192
 
193
+ const { uploads } = useMyUploads();
194
+ const myUploads = computed(() => featureFlags.FeatureUploadEnabled ? uploads.value.slice(0, MY_UPLOADS_LIMIT) : []);
195
+
165
196
  const saveHistory = ref(true);
166
197
 
167
198
  const removedGuids = ref<string[]>([]);
@@ -0,0 +1,41 @@
1
+ <!-- My videos: the user's uploads with status and visibility, behind the FeatureUploadEnabled flag. Data is a stub -->
2
+ <template>
3
+ <div class="_page _container">
4
+ <UiBreadcrumbs
5
+ :items="breadcrumbs"
6
+ class="_breadcrumbs"
7
+ />
8
+
9
+ <LibraryUploads/>
10
+ </div>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ import type { IBreadcrumbItem } from '../../types';
15
+
16
+ definePageMeta({ featureFlag: 'FeatureUploadEnabled' });
17
+
18
+ const { t } = useI18n();
19
+
20
+ useNoindex();
21
+
22
+ const { brandName } = useAppConfig();
23
+
24
+ useHead(() => ({
25
+ title: `${t('my_uploads.title')} — ${brandName}`,
26
+ }));
27
+
28
+ const breadcrumbs = [
29
+ {
30
+ name: `${t('navigation.main')}`,
31
+ to: '/',
32
+ },
33
+ {
34
+ name: `${t('navigation.library')}`,
35
+ to: '/library',
36
+ },
37
+ {
38
+ name: `${t('my_uploads.title')}`,
39
+ },
40
+ ] as IBreadcrumbItem[];
41
+ </script>
@@ -59,3 +59,12 @@ export const UPLOAD_LIMITS = {
59
59
  maxTags: 15,
60
60
  dailyUploads: 10,
61
61
  } as const;
62
+
63
+ export const MyUploadStatus = {
64
+ Processing: 'processing',
65
+ Review: 'review',
66
+ Declined: 'declined',
67
+ Published: 'published',
68
+ } as const;
69
+
70
+ export type MyUploadStatus = typeof MyUploadStatus[keyof typeof MyUploadStatus];
@@ -0,0 +1,80 @@
1
+ import { MyUploadStatus, Niche, UploadVisibility } from '../runtime';
2
+ import type { IMyUpload } from '../types';
3
+ import { VIDEO_POOL } from './videos';
4
+
5
+ const DAY = 24 * 3600;
6
+
7
+ export function getUploadsStub(now = Math.floor(Date.now() / 1000)): IMyUpload[] {
8
+ const [lake, roommates, road, cabin, gym] = [VIDEO_POOL[0]!, VIDEO_POOL[4]!, VIDEO_POOL[8]!, VIDEO_POOL[2]!, VIDEO_POOL[6]!];
9
+
10
+ return [
11
+ {
12
+ id: 'upload-1',
13
+ fileName: 'IMG_4821_final.mp4',
14
+ duration: 872,
15
+ status: MyUploadStatus.Processing,
16
+ visibility: UploadVisibility.Public,
17
+ niche: Niche.Gay,
18
+ },
19
+ {
20
+ id: 'upload-2',
21
+ fileName: 'cabin_weekend.mov',
22
+ title: 'Cabin weekend with my boyfriend, slow morning in bed',
23
+ duration: cabin.duration,
24
+ thumbUrls: cabin.thumbUrls,
25
+ status: MyUploadStatus.Review,
26
+ visibility: UploadVisibility.Unlisted,
27
+ niche: Niche.Gay,
28
+ },
29
+ {
30
+ id: 'upload-3',
31
+ fileName: 'gym_shower_v2.mp4',
32
+ title: 'Gym shower after leg day',
33
+ duration: gym.duration,
34
+ thumbUrls: gym.thumbUrls,
35
+ status: MyUploadStatus.Declined,
36
+ visibility: UploadVisibility.Public,
37
+ declineReason: 'Watermark from another site is visible in the corner of the video',
38
+ niche: Niche.Gay,
39
+ },
40
+ {
41
+ id: 'upload-4',
42
+ fileName: 'lake_house.mp4',
43
+ title: lake.title,
44
+ duration: lake.duration,
45
+ thumbUrls: lake.thumbUrls,
46
+ videoId: lake.id,
47
+ status: MyUploadStatus.Published,
48
+ visibility: UploadVisibility.Public,
49
+ views: 1420,
50
+ publishedAt: now - 2 * DAY,
51
+ niche: Niche.Gay,
52
+ },
53
+ {
54
+ id: 'upload-5',
55
+ fileName: 'roommates_night.mp4',
56
+ title: roommates.title,
57
+ duration: roommates.duration,
58
+ thumbUrls: roommates.thumbUrls,
59
+ videoId: roommates.id,
60
+ status: MyUploadStatus.Published,
61
+ visibility: UploadVisibility.Private,
62
+ views: 18240,
63
+ publishedAt: now - 14 * DAY,
64
+ niche: Niche.Gay,
65
+ },
66
+ {
67
+ id: 'upload-6',
68
+ fileName: 'road_trip_motel.mp4',
69
+ title: road.title,
70
+ duration: road.duration,
71
+ thumbUrls: road.thumbUrls,
72
+ videoId: road.id,
73
+ status: MyUploadStatus.Published,
74
+ visibility: UploadVisibility.Unlisted,
75
+ views: 640,
76
+ publishedAt: now - 31 * DAY,
77
+ niche: Niche.Gay,
78
+ },
79
+ ];
80
+ }
package/types/upload.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { UploadCheck, UploadCheckStatus, UploadVisibility } from '../runtime';
1
+ import type { MyUploadStatus, Niche, UploadCheck, UploadCheckStatus, UploadVisibility } from '../runtime';
2
+ import type { IThumbUrls } from './thumbs-urls';
2
3
 
3
4
  export interface IUploadFile {
4
5
  name: string;
@@ -38,3 +39,18 @@ export interface IUploadComboboxChip {
38
39
  label: string;
39
40
  image?: string | null;
40
41
  }
42
+
43
+ export interface IMyUpload {
44
+ id: string;
45
+ fileName: string;
46
+ title?: string;
47
+ duration: number;
48
+ status: MyUploadStatus;
49
+ visibility: UploadVisibility;
50
+ niche: Niche;
51
+ thumbUrls?: IThumbUrls;
52
+ videoId?: string;
53
+ declineReason?: string;
54
+ views?: number;
55
+ publishedAt?: number;
56
+ }