diff --git a/docs/feed-card-visual-system/README.md b/docs/feed-card-visual-system/README.md new file mode 100644 index 000000000..caac2d49f --- /dev/null +++ b/docs/feed-card-visual-system/README.md @@ -0,0 +1,157 @@ + + +# Единая визуальная система карточек ленты + +## Восстановление прерванной задачи + +База: `d4708cbc9ca7afbd245a4ff29940e2ab5dae0304` — актуальный `origin/dev` после #372; повторный fetch перед публикацией подтвердил тот же SHA. +Ветка: `fix/dev-feed-card-visual-system`. + +Проверены status, staged/unstaged diff, последние 20 коммитов, локальные worktree, remote refs и PR. +На remote изменений этой визуальной задачи нет. Найденный #372 уже merged; отдельного Draft PR или незавершённой remote-реализации этого ТЗ не было. +Незакоммиченные изменения другого ПК недоступны; их содержимое не восстанавливалось предположениями. +Чужие изменения в основном checkout и старых worktree оставлены как есть. Merge/rebase в процессе не обнаружены. + +На момент аудита: + +- **Готово:** #372 — row order, `limit=6`, append, ключ `typeModel:id`, категории, counts, published_at, маршруты и разбиение текста новости. +- **Частично:** badges/date/source/CTA уже присутствовали, но высоты, типографика и линия счётчиков расходились. +- **Не сделано:** общая геометрия, иерархия вакансии, ограничение списка навыков, адаптивные замеры и визуальная приёмка. + +Продолжена только визуальная часть от последней доступной DEV-базы. Уже готовая логика #372 не переписывалась. + +## Реализация и соответствие ТЗ + +| Пункты ТЗ | Результат | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2–6, 10–13 | Общий SCSS mixin: высота 420 px, padding 20 px, одинаковое скругление и строки type/date → источник → заголовок → описание → metadata → CTA. Четыре типа сохраняют свои данные и ссылки; фиолетовый «Новый проект» сохранён. | +| 7–9, 19 | Заголовок вакансии 18 px / 600, описание 12 px / 3 строки, первые 3 навыка + остаток `+N`; industry — вторичный chip 24 px. Длинные строки ограничены clamp/ellipsis, полный источник и названия доступны через title. | +| 14–16 | Все шесть desktop-фильтров используют одну сетку: область заголовка 32 px, count 18 px; count 14 px / 600, tabular numbers. Индивидуальных поправок для категорий нет. | +| 17–18 | Grid по строкам, две desktop-колонки, нечётная карточка слева. Сервисы, API, append, page size и ключи #372 неизменны. | +| 20 | До 1000 px — одна колонка карточек. На 390 и 768 px высота также 420 px, горизонтальной прокрутки нет. Существующий мобильный фильтр сохранён. | +| 21 | Нативные ссылки CTA, кнопки desktop-фильтров и лайка, semantic time, aria-pressed и focus-visible. CTA не вложены в другой интерактивный элемент. | +| 22 | Backend, React, API-контракт, counts, published_at, категории, tracking, likes, маршруты, права, dependencies, workflows не менялись. «Программы»/«Образование» остаются значениями текущего контракта. | +| 23–25 | Targeted и full tests, реальный браузер с Angular-компонентами, обычный/длинный контент, desktop/tablet/mobile, screenshots и числовые замеры. | + +CTA высотой 36 px занимает всю ширину content area: «Подробнее о вакансии», «Перейти в проект», «Перейти в профиль». +Фиксированные строки защищают положение CTA даже при отсутствии metadata или коротком тексте. Высота не зависит от текста. + +Новости вне ленты продолжают использовать прежний шаблон. Форма редактирования не получает фиксированную сетку preview. +В ленте сохранены существующие обработчики лайка/копирования, счётчик просмотров, carousel и ссылки на все файлы. +Для изображений ограничен только preview ленты через CSS custom property; остальные поверхности сохраняют естественную высоту. +Много файлов остаются доступны в ограниченной области вложений; карточка не растёт. + +## Проверки + +Среда: Windows, Node 20.20.2, npm ci по неизменённому lockfile. + +| Команда | Реальный результат | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `npm ci` | exit 0, dependencies/lockfile не менялись | +| Targeted, команда ниже | **27/27**, 12 файлов, exit 0 | +| `npm run test:ci` | exit 1: NG0401 в setup до выполнения тестов, 382 файла | +| Та же команда на чистой базе `d4708cbc…` | exit 1: тот же NG0401 в setup, 381 файл, тесты не запускались; status чистый | +| `npm run test:ci -- --pool=forks` | **1752/1752**, 382 файла, exit 0; unhandled errors нет | +| `npm run lint:ts` | exit 0, 0 errors, 6 существующих warnings об unused eslint-disable в logger/chat/message-input | +| Scoped Prettier | exit 0 | +| Scoped Stylelint всех изменённых SCSS + нового mixin | exit 0 | +| `npm run build:prod` | exit 0; существующие Angular/Sass/CommonJS warnings | +| `git diff --check` | exit 0 | + +После небольшого уточнения шаблона редактора и его regression assertion targeted повторены: 27/27. Test setup, pool по умолчанию, skips/excludes и exit codes не менялись. Штатный `test:ci` **не объявляется зелёным**. + +```powershell +npm run test:ci -- --pool=forks projects/social_platform/src/app/ui/pages/feed projects/social_platform/src/app/ui/widgets/news-card projects/social_platform/src/app/ui/widgets/feed-filter projects/social_platform/src/app/api/feed projects/social_platform/src/app/infrastructure/adapters/feed projects/social_platform/src/app/infrastructure/repository/feed +``` + +Покрытие: четыре badge/date, source/title/description/skills/industry/CTA, профильный маршрут, counts/единая DOM-структура, первые 6 + следующие 6 без перестановки DOM, совпадающие числовые ID разных типов, лайк/файлы, обычная карточка вне ленты и редактор. +Проверка реальной геометрии выполнялась в браузере, а не через jsdom. + +Особенность локального Windows build: существующий `build:sprite` с одинарными кавычками генерирует пустой SVG sprite. Эта побочная генерация отменена только в рабочей копии; sprite и scripts не входят в diff. Для визуальных проверок использован исходный sprite из DEV. Это ограничение штатной локальной сборки, не исправление этой задачи. + +## Браузерные замеры + +Масштаб 100%, реальные Angular FeedComponent / OpenVacancy / NewProject / NewsCard / FeedFilter и штатный SCSS office shell. +Только синтетические локальные данные; авторизация и живой backend не использовались. Это проверка UI, не live DEV end-to-end. +На всех размерах проверены четыре типа, длинные названия без пробелов, длинные описание/новость/industry и 6 навыков. + +| Viewport | Колонки | Все карточки W × H, px | Y шести counts | Y CTA по рядам | +| ---------- | ------- | ---------------------- | ------------------------------ | -------------------------------------- | +| 1440 × 900 | 2 | 423.328125 × 420 | 156, 156, 156, 156, 156, 156 | 570 / 1010 / 1450 | +| 1280 × 800 | 2 | 423.328125 × 420 | 156, 156, 156, 156, 156, 156 | 570 / 1010 / 1450 | +| 1024 × 768 | 2 | 368.75 × 420 | 156, 156, 156, 156, 156, 156 | 570 / 1010 / 1450 | +| 768 × 1024 | 1 | 753 × 420 | 138, 138 / 272, 272 / 406, 406 | 878 / 1318 / 1758 / 2198 / 2638 / 3078 | +| 390 × 844 | 1 | 375 × 420 | 138, 138 / 272, 272 / 406, 406 | 878 / 1318 / 1758 / 2198 / 2638 / 3078 | + +Координаты относительно документа локального fixture; scrollbar занимает 15 px. На desktop расхождение высот, widths и CTA внутри ряда — **0 px**. На mobile/tablet counts совпадают внутри каждого ряда фильтров. Длинный контент не изменяет эти значения. Горизонтального overflow нет. + +До правки при 1440 × 900: высоты карточек 392.59375 / 362.296875 / 314.59375 / 332.59375 / 392.59375 / 790.625 px. Counts Y: 147.390625 / 155.1875 / 147.390625 / 155.1875 / 155.1875 / 147.390625. +Сырые замеры: [geometry.json](geometry.json). Во всех карточках padding 20 px, radius 15 px, badge 26 px, CTA 36 px; вложенных интерактивных элементов 0 — [style-receipt.json](style-receipt.json). + +Также проверено в браузере: + +- Пятая карточка нечётного набора остаётся слева (X=365.828125), высота 420 px. +- Лайк в fixture: `aria-pressed=true → false`, `234 → 233` без перезагрузки, через прежний output/state. +- CTA href: vacancy `/office/vacancies/1`; project `/office/projects/51`; new project `/office/projects/1`; people `/office/profile/42`. +- Tab переводит фокус на нативную ссылку; `:focus-visible=true`, outline solid. +- Новость с изображениями и файлом: 420 px, overflow отсутствует, изображение загружено, ссылка download сохранена. +- Enter на кнопке фильтра переключает его active/aria-pressed через прежний обработчик. +- Console errors при smoke не обнаружены. + +Не проверялись: реальные backend-запросы, авторизованный live DEV, отправка лайка на сервер, реальное скачивание/clipboard и открытие detail-страниц. Их обработчики/маршруты не менялись. Общая логика переключения изображений carousel не изменялась; отдельная её функциональная приёмка не выполнялась. + +## Скриншоты + +Полная лента до / после: + +![Лента до](screenshots/feed-full-before.png) +![Лента после](screenshots/feed-full-after.png) + +Первый и второй ряды: + +![Вакансия и новость проекта](screenshots/row-1-after.png) +![Новый проект и новость человека](screenshots/row-2-after.png) + +Крупные планы: + +![Вакансия](screenshots/vacancy-closeup.png) +![Фильтры](screenshots/filters-closeup.png) + +Длинный контент и mobile: + +![Длинный контент](screenshots/desktop-long.png) +![Mobile до](screenshots/mobile-before.png) +![Mobile после](screenshots/mobile-after.png) + +Дополнительно: [1024 px](screenshots/desktop-1024-after.png), [1280 px](screenshots/desktop-1280-after.png), [tablet](screenshots/tablet-long.png), [mobile long](screenshots/mobile-long.png), [focus](screenshots/keyboard-focus.png), [вложения](screenshots/news-attachments.png). + +Крупные планы сняты с теми же реальными компонентами и шириной 423.328125 px: локальный fixture сдвигает canvas к началу viewport для нативного crop. Масштаб и размеры карточек не меняются. Полные страницы сняты без такого сдвига. + +## Воспроизводимость + +Временный fixture не включён в приложение и не добавляет route в продукт. Его исходники приложены как `.txt`: +`preview-main.ts.txt`, `preview-tsconfig.json.txt`, `preview-index.html.txt`, `serve-preview.py.txt`. +Скопировать их в игнорируемый `tmp/` как `surface-main.ts`, `surface-tsconfig.json`, `surface-index.html`, `serve-preview.py`. + +```powershell +npx ng build social_platform --configuration=development --browser tmp/surface-main.ts --ts-config tmp/surface-tsconfig.json --index tmp/surface-index.html --output-path tmp/preview +Copy-Item tmp/preview/browser/surface-index.html tmp/preview/browser/index.html +# Запустить приложенный HTTP helper из tmp/preview/browser с абсолютным путём к скрипту. +# /office/feed — 6 карточек; ?long — длинный контент; ?odd — 5 карточек. +# ?crop=vacancy / ?crop=row2 / ?crop=filter — крупные планы при 1440×900. +``` + +Базовый preview построен до правок компонентов из exact DEV; текущий — из этой ветки. API-слой заменён только в локальном fixture; приложение использует прежние сервисы. + +## Изменённые файлы + +- `ui/pages/feed/feed.component.scss` и новый `feed.component.spec.ts`: breakpoint и защита порядка/ключей DOM. +- `ui/pages/feed/open-vacancy/open-vacancy.component.{html,scss,ts,spec.ts}`: иерархия вакансии и нативный CTA; TS — только удаление неиспользуемого импорта ButtonComponent. +- `ui/pages/feed/new-project/new-project.component.{html,scss,ts,spec.ts}`: общая геометрия и нативный CTA; TS — только удаление того же импорта. +- `ui/widgets/feed-filter/feed-filter.component.{html,scss,spec.ts}`: одинаковая область названия/count и native buttons. +- `ui/widgets/news-card/news-card.component.{html,scss,spec.ts}`: локальная ветка preview ленты и регрессионные проверки. +- `ui/widgets/news-card/carousel/carousel.component.scss`: опциональная высота через CSS variable только для preview ленты. +- `projects/social_platform/src/styles/_feed-card.scss`: общая геометрия и типографика. +- `docs/feed-card-visual-system/`: этот отчёт, замеры, fixture и скриншоты. + +Пути `ui/...` относятся к `projects/social_platform/src/app/`. FeedComponent TS/HTML, NewsCard TS, весь API/domain/infrastructure и остальные страницы не изменены. diff --git a/docs/feed-card-visual-system/geometry.json b/docs/feed-card-visual-system/geometry.json new file mode 100644 index 000000000..a8e358637 --- /dev/null +++ b/docs/feed-card-visual-system/geometry.json @@ -0,0 +1,617 @@ +[ + { + "version": "before", + "viewport": 1440, + "cards": [ + { + "ctaY": 549.1875, + "h": 392.59375, + "w": 423.328125, + "x": 365.828125, + "y": 207.1875 + }, + { + "ctaY": 485.484375, + "h": 362.296875, + "w": 423.328125, + "x": 809.15625, + "y": 207.1875 + }, + { + "ctaY": 884.78125, + "h": 314.59375, + "w": 423.328125, + "x": 365.828125, + "y": 619.78125 + }, + { + "ctaY": 868.375, + "h": 332.59375, + "w": 423.328125, + "x": 809.15625, + "y": 619.78125 + }, + { + "ctaY": 1314.375, + "h": 392.59375, + "w": 423.328125, + "x": 365.828125, + "y": 972.375 + }, + { + "ctaY": 1679, + "h": 790.625, + "w": 423.328125, + "x": 809.15625, + "y": 972.375 + } + ], + "countY": [147.390625, 155.1875, 147.390625, 155.1875, 155.1875, 147.390625], + "overflow": false + }, + { + "version": "after", + "viewport": 1440, + "long": false, + "cards": [ + { + "ctaY": 570, + "h": 420, + "w": 423.328125, + "x": 365.828125, + "y": 207 + }, + { + "ctaY": 570, + "h": 420, + "w": 423.328125, + "x": 809.15625, + "y": 207 + }, + { + "ctaY": 1010, + "h": 420, + "w": 423.328125, + "x": 365.828125, + "y": 647 + }, + { + "ctaY": 1010, + "h": 420, + "w": 423.328125, + "x": 809.15625, + "y": 647 + }, + { + "ctaY": 1450, + "h": 420, + "w": 423.328125, + "x": 365.828125, + "y": 1087 + }, + { + "ctaY": 1450, + "h": 420, + "w": 423.328125, + "x": 809.15625, + "y": 1087 + } + ], + "countY": [156, 156, 156, 156, 156, 156], + "overflow": false, + "viewportHeight": 900 + }, + { + "version": "after", + "viewport": 1280, + "long": false, + "cards": [ + { + "ctaY": 570, + "h": 420, + "w": 423.328125, + "x": 285.828125, + "y": 207 + }, + { + "ctaY": 570, + "h": 420, + "w": 423.328125, + "x": 729.15625, + "y": 207 + }, + { + "ctaY": 1010, + "h": 420, + "w": 423.328125, + "x": 285.828125, + "y": 647 + }, + { + "ctaY": 1010, + "h": 420, + "w": 423.328125, + "x": 729.15625, + "y": 647 + }, + { + "ctaY": 1450, + "h": 420, + "w": 423.328125, + "x": 285.828125, + "y": 1087 + }, + { + "ctaY": 1450, + "h": 420, + "w": 423.328125, + "x": 729.15625, + "y": 1087 + } + ], + "countY": [156, 156, 156, 156, 156, 156], + "overflow": false, + "viewportHeight": 800 + }, + { + "version": "after", + "viewport": 1024, + "long": false, + "cards": [ + { + "ctaY": 570, + "h": 420, + "w": 368.75, + "x": 201.5, + "y": 207 + }, + { + "ctaY": 570, + "h": 420, + "w": 368.75, + "x": 590.25, + "y": 207 + }, + { + "ctaY": 1010, + "h": 420, + "w": 368.75, + "x": 201.5, + "y": 647 + }, + { + "ctaY": 1010, + "h": 420, + "w": 368.75, + "x": 590.25, + "y": 647 + }, + { + "ctaY": 1450, + "h": 420, + "w": 368.75, + "x": 201.5, + "y": 1087 + }, + { + "ctaY": 1450, + "h": 420, + "w": 368.75, + "x": 590.25, + "y": 1087 + } + ], + "countY": [156, 156, 156, 156, 156, 156], + "overflow": false, + "viewportHeight": 768 + }, + { + "version": "after", + "viewport": 390, + "long": false, + "cards": [ + { + "ctaY": 878, + "h": 420, + "w": 375, + "x": 0, + "y": 515 + }, + { + "ctaY": 1318, + "h": 420, + "w": 375, + "x": 0, + "y": 955 + }, + { + "ctaY": 1758, + "h": 420, + "w": 375, + "x": 0, + "y": 1395 + }, + { + "ctaY": 2198, + "h": 420, + "w": 375, + "x": 0, + "y": 1835 + }, + { + "ctaY": 2638, + "h": 420, + "w": 375, + "x": 0, + "y": 2275 + }, + { + "ctaY": 3078, + "h": 420, + "w": 375, + "x": 0, + "y": 2715 + } + ], + "countY": [138, 138, 272, 272, 406, 406], + "overflow": false, + "viewportHeight": 844 + }, + { + "version": "after", + "viewport": 390, + "long": true, + "cards": [ + { + "ctaY": 878, + "h": 420, + "w": 375, + "x": 0, + "y": 515 + }, + { + "ctaY": 1318, + "h": 420, + "w": 375, + "x": 0, + "y": 955 + }, + { + "ctaY": 1758, + "h": 420, + "w": 375, + "x": 0, + "y": 1395 + }, + { + "ctaY": 2198, + "h": 420, + "w": 375, + "x": 0, + "y": 1835 + }, + { + "ctaY": 2638, + "h": 420, + "w": 375, + "x": 0, + "y": 2275 + }, + { + "ctaY": 3078, + "h": 420, + "w": 375, + "x": 0, + "y": 2715 + } + ], + "countY": [138, 138, 272, 272, 406, 406], + "overflow": false, + "viewportHeight": 844 + }, + { + "version": "after", + "viewport": 768, + "long": true, + "cards": [ + { + "ctaY": 878, + "h": 420, + "w": 753, + "x": 0, + "y": 515 + }, + { + "ctaY": 1318, + "h": 420, + "w": 753, + "x": 0, + "y": 955 + }, + { + "ctaY": 1758, + "h": 420, + "w": 753, + "x": 0, + "y": 1395 + }, + { + "ctaY": 2198, + "h": 420, + "w": 753, + "x": 0, + "y": 1835 + }, + { + "ctaY": 2638, + "h": 420, + "w": 753, + "x": 0, + "y": 2275 + }, + { + "ctaY": 3078, + "h": 420, + "w": 753, + "x": 0, + "y": 2715 + } + ], + "countY": [138, 138, 272, 272, 406, 406], + "overflow": false, + "viewportHeight": 1024 + }, + { + "version": "after", + "viewport": 1024, + "long": true, + "cards": [ + { + "ctaY": 570, + "h": 420, + "w": 368.75, + "x": 201.5, + "y": 207 + }, + { + "ctaY": 570, + "h": 420, + "w": 368.75, + "x": 590.25, + "y": 207 + }, + { + "ctaY": 1010, + "h": 420, + "w": 368.75, + "x": 201.5, + "y": 647 + }, + { + "ctaY": 1010, + "h": 420, + "w": 368.75, + "x": 590.25, + "y": 647 + }, + { + "ctaY": 1450, + "h": 420, + "w": 368.75, + "x": 201.5, + "y": 1087 + }, + { + "ctaY": 1450, + "h": 420, + "w": 368.75, + "x": 590.25, + "y": 1087 + } + ], + "countY": [156, 156, 156, 156, 156, 156], + "overflow": false, + "viewportHeight": 768 + }, + { + "version": "after", + "viewport": 1280, + "long": true, + "cards": [ + { + "ctaY": 570, + "h": 420, + "w": 423.328125, + "x": 285.828125, + "y": 207 + }, + { + "ctaY": 570, + "h": 420, + "w": 423.328125, + "x": 729.15625, + "y": 207 + }, + { + "ctaY": 1010, + "h": 420, + "w": 423.328125, + "x": 285.828125, + "y": 647 + }, + { + "ctaY": 1010, + "h": 420, + "w": 423.328125, + "x": 729.15625, + "y": 647 + }, + { + "ctaY": 1450, + "h": 420, + "w": 423.328125, + "x": 285.828125, + "y": 1087 + }, + { + "ctaY": 1450, + "h": 420, + "w": 423.328125, + "x": 729.15625, + "y": 1087 + } + ], + "countY": [156, 156, 156, 156, 156, 156], + "overflow": false, + "viewportHeight": 800 + }, + { + "version": "after", + "viewport": 1440, + "long": true, + "cards": [ + { + "ctaY": 570, + "h": 420, + "w": 423.328125, + "x": 365.828125, + "y": 207 + }, + { + "ctaY": 570, + "h": 420, + "w": 423.328125, + "x": 809.15625, + "y": 207 + }, + { + "ctaY": 1010, + "h": 420, + "w": 423.328125, + "x": 365.828125, + "y": 647 + }, + { + "ctaY": 1010, + "h": 420, + "w": 423.328125, + "x": 809.15625, + "y": 647 + }, + { + "ctaY": 1450, + "h": 420, + "w": 423.328125, + "x": 365.828125, + "y": 1087 + }, + { + "ctaY": 1450, + "h": 420, + "w": 423.328125, + "x": 809.15625, + "y": 1087 + } + ], + "countY": [156, 156, 156, 156, 156, 156], + "overflow": false, + "viewportHeight": 900 + }, + { + "version": "before", + "viewport": 390, + "long": false, + "cards": [ + { + "ctaY": 810.78125, + "h": 392.59375, + "w": 375, + "x": 0, + "y": 468.78125 + }, + { + "ctaY": 1204.96875, + "h": 407.59375, + "w": 375, + "x": 0, + "y": 881.375 + }, + { + "ctaY": 1573.96875, + "h": 314.59375, + "w": 375, + "x": 0, + "y": 1308.96875 + }, + { + "ctaY": 1913.15625, + "h": 353.59375, + "w": 375, + "x": 0, + "y": 1643.5625 + }, + { + "ctaY": 2359.15625, + "h": 392.59375, + "w": 375, + "x": 0, + "y": 2017.15625 + }, + { + "ctaY": 3133.34375, + "h": 787.59375, + "w": 375, + "x": 0, + "y": 2429.75 + } + ], + "countY": [121.59375, 121.59375, 240.1875, 240.1875, 358.78125, 358.78125], + "overflow": false, + "viewportHeight": 844 + }, + { + "version": "after-odd", + "viewport": 1440, + "long": false, + "cards": [ + { + "ctaY": 570, + "h": 420, + "w": 423.328125, + "x": 365.828125, + "y": 207 + }, + { + "ctaY": 570, + "h": 420, + "w": 423.328125, + "x": 809.15625, + "y": 207 + }, + { + "ctaY": 1010, + "h": 420, + "w": 423.328125, + "x": 365.828125, + "y": 647 + }, + { + "ctaY": 1010, + "h": 420, + "w": 423.328125, + "x": 809.15625, + "y": 647 + }, + { + "ctaY": 1450, + "h": 420, + "w": 423.328125, + "x": 365.828125, + "y": 1087 + } + ], + "countY": [156, 156, 156, 156, 156, 156], + "overflow": false, + "viewportHeight": 900 + } +] diff --git a/docs/feed-card-visual-system/preview-index.html.txt b/docs/feed-card-visual-system/preview-index.html.txt new file mode 100644 index 000000000..2f2bc65a9 --- /dev/null +++ b/docs/feed-card-visual-system/preview-index.html.txt @@ -0,0 +1 @@ +Раздел проектов — локальная проверка diff --git a/docs/feed-card-visual-system/preview-main.ts.txt b/docs/feed-card-visual-system/preview-main.ts.txt new file mode 100644 index 000000000..605375f1c --- /dev/null +++ b/docs/feed-card-visual-system/preview-main.ts.txt @@ -0,0 +1,40 @@ +import '@angular/compiler'; +import { Component, provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { provideRouter, Router, RouterOutlet } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { API_URL } from '@corelib'; +import { FeedComponent } from '../projects/social_platform/src/app/ui/pages/feed/feed.component'; +import { FeedInfoService } from '@api/feed/facades/feed-info.service'; +import { FeedUIInfoService } from '@api/feed/facades/ui/feed-ui-info.service'; +import { IndustryRepositoryPort } from '@domain/industry/ports/industry.repository.port'; +import { FeedNews } from '@domain/news/project-news.model'; + +const params=new URLSearchParams(location.search); +const long=params.has('long'); +const avatar='/assets/images/profile/main.svg'; +const date='2026-08-29T12:00:00Z'; +const project={id:51,name:long?'ОченьДлинноеНазваниеПроектаБезПробелов'.repeat(6):'Команда цифровых решений',imageAddress:avatar,industry:1,leader:7,shortDescription:long?'Подробное описание нового проекта '.repeat(60):'Помогаем студентам находить команду и запускать полезные инициативы.',viewsCount:0}; +const vacancy=(id:number)=>({typeModel:'vacancy',publishedAt:date,content:{id,project,role:long?'ВедущийСпециалистПоТестированиюСложныхСистем'.repeat(6):'Тестировщик (QA)',description:long?'Очень подробное описание вакансии и задач '.repeat(60):'Помогите проверить новую платформу: тестируйте API, находите ошибки и улучшайте опыт пользователей.',requiredSkills:['QA','Postman','API','SQL','Git','Аналитика'].map((name,id)=>({id,name:long?name+'ОченьДлинныйНавыкБезПробелов'.repeat(3):name}))}}); +const news=(id:number,people=false,media=false)=>({typeModel:'news',publishedAt:date,content:{...FeedNews.default(),id,name:people?'Анна Смирнова':project.name,imageAddress:avatar,text:long?'ОченьДлинныйЗаголовокБезПробелов'.repeat(8)+'. '+'Большой текст новости '.repeat(100):(people?'Поделилась результатами исследования. ':'Запустили открытую лабораторию. ')+'Приглашаем познакомиться с первыми результатами и обсудить идеи следующего этапа.',contentObject:people?{id:42,email:'fixture@example.test'}:project,files:media?[{link:'/assets/images/profile/main.svg',name:'Иллюстрация',mimeType:'image/svg+xml',size:100},{link:'/assets/images/profile/skills.svg',name:'Вторая иллюстрация',mimeType:'image/svg+xml',size:100},{link:'/assets/images/profile/main.svg',name:'Описание проекта.pdf',mimeType:'application/pdf',size:1024}]:[]}}); +const items:any[]=[vacancy(1),news(2),{typeModel:'project',publishedAt:date,content:{...project,id:1}},news(4,true),vacancy(5),news(6,false,true)]; +if(params.has('odd')) items.pop(); +const crop=params.get('crop'); +const cropY=crop==='row2'?647:crop==='filter'?58:207; +const cropStyle=crop?`position:relative;left:-365.828125px;top:-${cropY}px;width:1425px`:''; +const ui=new FeedUIInfoService(); +ui.applyInitializationFeedNewsEvent({count:6,next:'',previous:'',results:items,counts:{all:86,project:56,vacancy:3,news:27,partnerprogram:0,education:0}}); +@Component({selector:'app-preview',imports:[RouterOutlet],styleUrls:['../projects/social_platform/src/app/ui/pages/office/office.component.scss'],template:`
`}) +class Preview { cropStyle=cropStyle; } +TestBed.initTestEnvironment(BrowserTestingModule,platformBrowserTesting()); +TestBed.configureTestingModule({imports:[Preview],providers:[provideZonelessChangeDetection(),provideHttpClient(),provideNoopAnimations(),provideRouter([{path:'office/feed',component:FeedComponent},{path:'',redirectTo:'office/feed',pathMatch:'full'}]),{provide:API_URL,useValue:'/fixture-api'},{provide:IndustryRepositoryPort,useValue:{getOne:()=>({id:1,name:long?'ОченьДлинноеНазваниеОтраслиБезПробелов'.repeat(6):'Entertainment Tech'})}}]}) +.overrideComponent(FeedComponent,{set:{providers:[{provide:FeedUIInfoService,useValue:ui},{provide:FeedInfoService,useValue:{initializationFeedNews(){},initScroll(){},destroy(){},onLike(id:number){const index=ui.feedItems().findIndex(x=>x.typeModel==='news'&&x.content.id===id);ui.applyLikeNews(index)}}}]}}); +TestBed.compileComponents().then(async()=>{ +const fixture=TestBed.createComponent(Preview); +document.body.appendChild(fixture.nativeElement); +fixture.autoDetectChanges(); +await TestBed.inject(Router).navigateByUrl(location.pathname+location.search); + +}); diff --git a/docs/feed-card-visual-system/preview-tsconfig.json.txt b/docs/feed-card-visual-system/preview-tsconfig.json.txt new file mode 100644 index 000000000..28c204dff --- /dev/null +++ b/docs/feed-card-visual-system/preview-tsconfig.json.txt @@ -0,0 +1 @@ +{"extends":"../tsconfig.json","compilerOptions":{"outDir":"../out-tsc/surface-preview","types":[]},"files":["surface-main.ts"],"include":["../projects/**/*.d.ts"]} diff --git a/docs/feed-card-visual-system/screenshots/desktop-1024-after.png b/docs/feed-card-visual-system/screenshots/desktop-1024-after.png new file mode 100644 index 000000000..61ed4d168 Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/desktop-1024-after.png differ diff --git a/docs/feed-card-visual-system/screenshots/desktop-1280-after.png b/docs/feed-card-visual-system/screenshots/desktop-1280-after.png new file mode 100644 index 000000000..17c120ad2 Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/desktop-1280-after.png differ diff --git a/docs/feed-card-visual-system/screenshots/desktop-1440-after.png b/docs/feed-card-visual-system/screenshots/desktop-1440-after.png new file mode 100644 index 000000000..8b4aec58c Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/desktop-1440-after.png differ diff --git a/docs/feed-card-visual-system/screenshots/desktop-1440-before.png b/docs/feed-card-visual-system/screenshots/desktop-1440-before.png new file mode 100644 index 000000000..5b18235ed Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/desktop-1440-before.png differ diff --git a/docs/feed-card-visual-system/screenshots/desktop-long.png b/docs/feed-card-visual-system/screenshots/desktop-long.png new file mode 100644 index 000000000..16e764a99 Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/desktop-long.png differ diff --git a/docs/feed-card-visual-system/screenshots/feed-full-after.png b/docs/feed-card-visual-system/screenshots/feed-full-after.png new file mode 100644 index 000000000..17cf66107 Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/feed-full-after.png differ diff --git a/docs/feed-card-visual-system/screenshots/feed-full-before.png b/docs/feed-card-visual-system/screenshots/feed-full-before.png new file mode 100644 index 000000000..7c3f73572 Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/feed-full-before.png differ diff --git a/docs/feed-card-visual-system/screenshots/filters-closeup.png b/docs/feed-card-visual-system/screenshots/filters-closeup.png new file mode 100644 index 000000000..2c895b983 Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/filters-closeup.png differ diff --git a/docs/feed-card-visual-system/screenshots/keyboard-focus.png b/docs/feed-card-visual-system/screenshots/keyboard-focus.png new file mode 100644 index 000000000..51adbdf08 Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/keyboard-focus.png differ diff --git a/docs/feed-card-visual-system/screenshots/mobile-after.png b/docs/feed-card-visual-system/screenshots/mobile-after.png new file mode 100644 index 000000000..8a57f7417 Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/mobile-after.png differ diff --git a/docs/feed-card-visual-system/screenshots/mobile-before.png b/docs/feed-card-visual-system/screenshots/mobile-before.png new file mode 100644 index 000000000..6b3e690c5 Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/mobile-before.png differ diff --git a/docs/feed-card-visual-system/screenshots/mobile-long.png b/docs/feed-card-visual-system/screenshots/mobile-long.png new file mode 100644 index 000000000..d6d4d0b99 Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/mobile-long.png differ diff --git a/docs/feed-card-visual-system/screenshots/news-attachments.png b/docs/feed-card-visual-system/screenshots/news-attachments.png new file mode 100644 index 000000000..da78bce5d Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/news-attachments.png differ diff --git a/docs/feed-card-visual-system/screenshots/row-1-after.png b/docs/feed-card-visual-system/screenshots/row-1-after.png new file mode 100644 index 000000000..616489be8 Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/row-1-after.png differ diff --git a/docs/feed-card-visual-system/screenshots/row-2-after.png b/docs/feed-card-visual-system/screenshots/row-2-after.png new file mode 100644 index 000000000..4ae29db3e Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/row-2-after.png differ diff --git a/docs/feed-card-visual-system/screenshots/tablet-long.png b/docs/feed-card-visual-system/screenshots/tablet-long.png new file mode 100644 index 000000000..7740ae4aa Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/tablet-long.png differ diff --git a/docs/feed-card-visual-system/screenshots/vacancy-closeup.png b/docs/feed-card-visual-system/screenshots/vacancy-closeup.png new file mode 100644 index 000000000..5e4a66e5a Binary files /dev/null and b/docs/feed-card-visual-system/screenshots/vacancy-closeup.png differ diff --git a/docs/feed-card-visual-system/serve-preview.py.txt b/docs/feed-card-visual-system/serve-preview.py.txt new file mode 100644 index 000000000..c0a1c30d1 --- /dev/null +++ b/docs/feed-card-visual-system/serve-preview.py.txt @@ -0,0 +1,9 @@ +import sys +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit +class Handler(SimpleHTTPRequestHandler): + def do_GET(self): + if urlsplit(self.path).path.startswith(('/office/','/card-reference','/activity-reference')): + self.path='/index.html' + super().do_GET() +ThreadingHTTPServer(('127.0.0.1',int(sys.argv[1]) if len(sys.argv)>1 else 4307),Handler).serve_forever() diff --git a/docs/feed-card-visual-system/style-receipt.json b/docs/feed-card-visual-system/style-receipt.json new file mode 100644 index 000000000..8c3d58bbf --- /dev/null +++ b/docs/feed-card-visual-system/style-receipt.json @@ -0,0 +1,53 @@ +{ + "cards": [ + { + "badgeHeight": 26, + "ctaFont": "12px", + "ctaHeight": 36, + "padding": "20px", + "radius": "15px", + "titleFont": "18px" + }, + { + "badgeHeight": 26, + "ctaFont": "12px", + "ctaHeight": 36, + "padding": "20px", + "radius": "15px", + "titleFont": "18px" + }, + { + "badgeHeight": 26, + "ctaFont": "12px", + "ctaHeight": 36, + "padding": "20px", + "radius": "15px", + "titleFont": "18px" + }, + { + "badgeHeight": 26, + "ctaFont": "12px", + "ctaHeight": 36, + "padding": "20px", + "radius": "15px", + "titleFont": "18px" + }, + { + "badgeHeight": 26, + "ctaFont": "12px", + "ctaHeight": 36, + "padding": "20px", + "radius": "15px", + "titleFont": "18px" + }, + { + "badgeHeight": 26, + "ctaFont": "12px", + "ctaHeight": 36, + "padding": "20px", + "radius": "15px", + "titleFont": "18px" + } + ], + "nestedInteractive": 0 +} diff --git a/projects/social_platform/src/app/ui/pages/feed/feed.component.scss b/projects/social_platform/src/app/ui/pages/feed/feed.component.scss index 0fb97a489..fa8f1ffcf 100644 --- a/projects/social_platform/src/app/ui/pages/feed/feed.component.scss +++ b/projects/social_platform/src/app/ui/pages/feed/feed.component.scss @@ -12,7 +12,7 @@ gap: 20px; align-items: start; - @media (max-width: 800px) { + @media (max-width: 999px) { grid-template-columns: minmax(0, 1fr); } diff --git a/projects/social_platform/src/app/ui/pages/feed/feed.component.spec.ts b/projects/social_platform/src/app/ui/pages/feed/feed.component.spec.ts new file mode 100644 index 000000000..0fbaa8f6f --- /dev/null +++ b/projects/social_platform/src/app/ui/pages/feed/feed.component.spec.ts @@ -0,0 +1,120 @@ +/** @format */ + +import { TestBed } from "@angular/core/testing"; +import { provideRouter } from "@angular/router"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { API_URL } from "@corelib"; +import { FeedInfoService } from "@api/feed/facades/feed-info.service"; +import { FeedUIInfoService } from "@api/feed/facades/ui/feed-ui-info.service"; +import { FeedItem, FeedProject } from "@domain/feed/feed-item.model"; +import { FeedNews } from "@domain/news/project-news.model"; +import { Vacancy } from "@domain/vacancy/vacancy.model"; +import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; +import { success } from "@domain/shared/async-state"; +import { FeedComponent } from "./feed.component"; + +describe("FeedComponent: порядок и представление карточек #372", () => { + const project: FeedProject = { + id: 1, + name: "Проект", + shortDescription: "Описание", + imageAddress: "", + industry: 1, + leader: 7, + viewsCount: 0, + }; + const date = "2026-08-29T12:00:00Z"; + const news = { + ...FeedNews.default(), + id: 1, + name: "Источник", + text: "Первая строка. Подробности", + files: [], + contentObject: { id: 1, industry: 1 }, + }; + const initial: FeedItem[] = [ + { + typeModel: "vacancy", + publishedAt: date, + content: { + id: 1, + project, + role: "QA", + description: "Задачи", + requiredSkills: [], + } as unknown as Vacancy, + }, + { typeModel: "news", publishedAt: date, content: news }, + { typeModel: "project", publishedAt: date, content: project }, + { + typeModel: "news", + publishedAt: date, + content: { ...news, id: 2, contentObject: { id: 42, email: "fixture@example.test" } }, + }, + { typeModel: "project", publishedAt: date, content: { ...project, id: 2 } }, + { typeModel: "project", publishedAt: date, content: { ...project, id: 3 } }, + ]; + + it("сохраняет шесть начальных карточек, ключи type:id и append в DOM без перестановки", async () => { + const ui = new FeedUIInfoService(); + ui.applyInitializationFeedNewsEvent({ count: 12, next: "", previous: "", results: initial }); + await TestBed.configureTestingModule({ + imports: [FeedComponent, HttpClientTestingModule], + providers: [ + provideRouter([]), + { provide: API_URL, useValue: "" }, + { provide: IndustryRepositoryPort, useValue: { getOne: () => ({ name: "IT" }) } }, + ], + }) + .overrideComponent(FeedComponent, { + set: { + providers: [ + { provide: FeedUIInfoService, useValue: ui }, + { + provide: FeedInfoService, + useValue: { initializationFeedNews: vi.fn(), initScroll: vi.fn(), destroy: vi.fn() }, + }, + ], + }, + }) + .compileComponents(); + const fixture = TestBed.createComponent(FeedComponent); + fixture.detectChanges(); + const root: HTMLElement = fixture.nativeElement; + const cards = () => Array.from(root.querySelectorAll(".page__item")); + const before = cards(); + expect(before.map(card => card.dataset["key"])).toEqual([ + "vacancy:1", + "news:1", + "project:1", + "news:2", + "project:2", + "project:3", + ]); + expect( + before.slice(0, 4).map(card => card.querySelector(".feed-card__type")?.textContent?.trim()), + ).toEqual(["Новая вакансия", "Новости проекта", "Новый проект", "Новости людей"]); + expect( + before.every(card => card.querySelector("time")?.getAttribute("datetime") === date), + ).toBe(true); + expect(before[3].querySelector(".feed-card__cta")?.getAttribute("href")).toBe( + "/office/profile/42", + ); + + const next: FeedItem[] = [4, 5, 6, 7, 8, 9].map(id => ({ + typeModel: "project", + content: { ...project, id }, + publishedAt: date, + })); + ui.feedItems$.set(success([...initial, ...next])); + fixture.detectChanges(); + expect(cards()).toHaveLength(12); + expect(cards().slice(0, 6)).toEqual(before); + expect( + cards() + .slice(6) + .map(card => card.dataset["key"]), + ).toEqual(["project:4", "project:5", "project:6", "project:7", "project:8", "project:9"]); + expect(ui.perFetchTake()).toBe(6); + }); +}); diff --git a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.html b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.html index 47cf19a81..fc50c49e4 100644 --- a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.html +++ b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.html @@ -1,36 +1,37 @@ -
-
- Новый проект +
+ Новый проект @if (publishedAt()) { -
- -
- -

{{ feedItem().name }}

+
+ +
+ Проект + {{ feedItem().name }} +
- - @if (feedItem().shortDescription) { -

{{ feedItem().shortDescription }}

- } - - @if (industryRepository.getOne(feedItem().industry); as industry) { - {{ industry.name }} - } - - {{ feedItem().name }} +

{{ feedItem().shortDescription }}

+ + Перейти в проект - Перейти в проект -
diff --git a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.scss b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.scss index 26c03819b..9dda78b6c 100644 --- a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.scss +++ b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.scss @@ -1,74 +1,11 @@ -.card { - display: flex; - flex-direction: column; - gap: 18px; - min-height: 100%; - padding: 24px; - color: var(--white); - background: var(--accent); - border-radius: var(--rounded-xl); - - &__meta { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - justify-content: space-between; - } - - &__type { - padding: 7px 12px; - font-size: 12px; - font-weight: 600; - color: var(--accent); - background: var(--white); - border-radius: var(--rounded-xxl); - } - - &__date { - font-size: 12px; - } +@use "styles/feed-card"; - &__heading { - display: flex; - gap: 16px; - align-items: center; - min-width: 0; - } - - &__title { - font-size: 20px; - font-weight: 700; - line-height: 1.3; - overflow-wrap: anywhere; - } - - /* stylelint-disable value-no-vendor-prefix */ - &__description { - display: -webkit-box; - overflow: hidden; - font-size: 14px; - line-height: 1.5; - overflow-wrap: anywhere; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - } - /* stylelint-enable value-no-vendor-prefix */ - - &__industry { - align-self: flex-start; - min-height: 32px; - padding: 7px 14px; - font-size: 14px; - font-weight: 600; - line-height: 18px; - color: var(--accent); - background: var(--white); - border-radius: var(--rounded-xxl); - } +.card { + --feed-background: var(--accent); + --feed-text: var(--white); + --feed-secondary: var(--white); + --feed-cta-text: var(--accent-dark); + --feed-cta-background: var(--white); - &__cta { - align-self: flex-start; - margin-top: auto; - } + @include feed-card.layout; } diff --git a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.spec.ts b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.spec.ts index 7c542ea77..16e9c260d 100644 --- a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.spec.ts @@ -36,5 +36,7 @@ describe("NewProjectComponent", () => { ); expect(element.querySelector(".card__industry")?.textContent?.trim()).toBe("Инженерия"); expect(element.querySelector(".card__cta")?.textContent).toContain("Перейти в проект"); + expect(element.querySelector("a.card__cta")?.getAttribute("href")).toBe("/office/projects/5"); + expect(element.querySelector("time")?.getAttribute("datetime")).toBe("2026-09-23T12:00:00Z"); }); }); diff --git a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.ts b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.ts index e0c979b23..46646eef0 100644 --- a/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.ts +++ b/projects/social_platform/src/app/ui/pages/feed/new-project/new-project.component.ts @@ -7,12 +7,11 @@ import { FeedProject } from "@domain/feed/feed-item.model"; import { AppRoutes } from "@api/paths/app-routes"; import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; -import { ButtonComponent } from "@ui/primitives"; /** Карточка нового проекта в общей ленте. */ @Component({ selector: "app-new-project", - imports: [RouterLink, DayjsPipe, AvatarComponent, ButtonComponent], + imports: [RouterLink, DayjsPipe, AvatarComponent], templateUrl: "./new-project.component.html", styleUrl: "./new-project.component.scss", changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.html b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.html index 1a52a730f..c4ee6e578 100644 --- a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.html +++ b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.html @@ -1,55 +1,61 @@ -
-
- Новая вакансия +
+ Новая вакансия @if (publishedAt()) { -
- - @if (feedItem().project; as project) { -
- -
- Проект / компания - {{ project.name }} +
+ @if (feedItem().project; as project) { + +
+ Проект / компания + {{ project.name }}
-
- } - -

{{ feedItem().role }}

- - @if (feedItem().description) { -

- } - - @if (feedItem().requiredSkills.length) { -
    - @for (skill of feedItem().requiredSkills.slice(0, 5); track skill.id) { -
  • {{ skill.name }}
  • + } +
+

{{ feedItem().role }}

+

+
diff --git a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.scss b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.scss index 8c1d7409f..69b26fc81 100644 --- a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.scss +++ b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.scss @@ -1,108 +1,5 @@ -.card { - display: flex; - flex-direction: column; - gap: 14px; - min-height: 100%; - padding: 24px; - background: var(--light-white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xl); - - &__meta { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - justify-content: space-between; - } - - &__type { - padding: 7px 12px; - font-size: 12px; - font-weight: 600; - color: var(--accent); - background: var(--white); - border: 1px solid var(--accent); - border-radius: var(--rounded-xxl); - } - - &__date, - &__eyebrow { - font-size: 12px; - color: var(--dark-grey); - } - - &__source { - display: flex; - gap: 12px; - align-items: center; - min-width: 0; - } - - &__source-text { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; - - a { - font-size: 14px; - font-weight: 600; - color: var(--black); - overflow-wrap: anywhere; - } - } +@use "styles/feed-card"; - &__title { - font-size: 20px; - font-weight: 700; - line-height: 1.3; - color: var(--black); - overflow-wrap: anywhere; - } - - /* stylelint-disable value-no-vendor-prefix */ - &__description { - display: -webkit-box; - overflow: hidden; - font-size: 14px; - line-height: 1.5; - color: var(--grey-for-text); - overflow-wrap: anywhere; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - } - /* stylelint-enable value-no-vendor-prefix */ - - &__skills { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - &__skill { - padding: 6px 10px; - font-size: 12px; - color: var(--accent); - background: var(--white); - border: 1px solid var(--medium-grey-for-outline); - border-radius: var(--rounded-xxl); - } - - &__industry { - align-self: flex-start; - min-height: 32px; - padding: 7px 14px; - font-size: 14px; - font-weight: 600; - line-height: 18px; - color: var(--white); - background: var(--green); - border-radius: var(--rounded-xxl); - } - - &__cta { - align-self: flex-start; - margin-top: auto; - } +.card { + @include feed-card.layout; } diff --git a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.spec.ts b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.spec.ts index 04cac7959..8c147c26e 100644 --- a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.spec.ts @@ -41,5 +41,25 @@ describe("OpenVacancyComponent", () => { ).toEqual(["Angular", "TypeScript"]); expect(element.querySelector(".card__industry")?.textContent?.trim()).toBe("IT"); expect(element.querySelector(".card__cta")?.textContent).toContain("Подробнее о вакансии"); + expect(element.querySelector("a.card__cta")?.getAttribute("href")).toBe("/office/vacancies/7"); + expect(element.querySelector("time")?.getAttribute("datetime")).toBe("2026-09-23T12:00:00Z"); + + fixture.componentRef.setInput("feedItem", { + ...fixture.componentInstance.feedItem(), + requiredSkills: ["QA", "Postman", "API", "SQL", "Git", "Аналитика"].map((name, id) => ({ + id, + name, + })), + }); + fixture.detectChanges(); + expect( + Array.from(element.querySelectorAll(".card__skills li")).map(node => + node.textContent?.trim(), + ), + ).toEqual(["QA", "Postman", "API", "+3"]); + expect(element.querySelector(".card__skills li:last-child")?.getAttribute("aria-label")).toBe( + "Ещё навыков: 3", + ); + expect(fixture.componentInstance.feedItem().requiredSkills).toHaveLength(6); }); }); diff --git a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.ts b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.ts index d276f6367..512e4bb9d 100644 --- a/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.ts +++ b/projects/social_platform/src/app/ui/pages/feed/open-vacancy/open-vacancy.component.ts @@ -7,19 +7,11 @@ import { Vacancy } from "@domain/vacancy/vacancy.model"; import { AppRoutes } from "@api/paths/app-routes"; import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port"; import { AvatarComponent } from "@ui/primitives/avatar/avatar.component"; -import { ButtonComponent } from "@ui/primitives"; /** Карточка вакансии в ленте: источник, роль, описание, навыки и переход к вакансии. */ @Component({ selector: "app-open-vacancy", - imports: [ - RouterLink, - DayjsPipe, - ParseLinksPipe, - ParseBreaksPipe, - AvatarComponent, - ButtonComponent, - ], + imports: [RouterLink, DayjsPipe, ParseLinksPipe, ParseBreaksPipe, AvatarComponent], templateUrl: "./open-vacancy.component.html", styleUrl: "./open-vacancy.component.scss", changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.html b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.html index 2dfb9185a..9d6772d95 100644 --- a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.html +++ b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.html @@ -32,23 +32,26 @@
@for (filterItem of feedFilterOptions; track $index) { -
-
+
-

{{ filterItem.name }}

+ + {{ filterItem.name }} {{ categoryCount(filterItem.value) }} -
+ }
diff --git a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.scss b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.scss index 8a503fce5..ac06a6af0 100644 --- a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.scss +++ b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.scss @@ -75,19 +75,24 @@ .filter { display: grid; - grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 30px 12px; padding-top: 25px; + @include responsive.apply-desktop { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + &__option { position: relative; - display: flex; - flex-direction: column; + display: grid; + grid-template-rows: 32px 18px; + row-gap: 4px; align-items: center; - justify-content: center; + justify-items: center; min-width: 0; - min-height: 88px; - padding: 36px 10px 12px; + height: 104px; + padding: 36px 6px 12px; cursor: pointer; background-color: var(--light-white); border: 0.5px solid var(--medium-grey-for-outline); @@ -109,7 +114,7 @@ background-color: var(--accent); } - p { + .filter__title { color: var(--accent); } } @@ -133,17 +138,26 @@ } } - p { - color: var(--grey-for-text); - text-align: center; + &:focus-visible { + outline: 2px solid var(--accent-dark); + outline-offset: 3px; } } + // Одна двухстрочная область названия задаёт общую базовую линию всех счётчиков. + &__title { + font-size: 11px; + line-height: 16px; + color: var(--grey-for-text); + text-align: center; + } + &__count { display: block; - margin-top: 4px; font-size: 14px; font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 18px; color: var(--accent); } diff --git a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.spec.ts b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.spec.ts index 9f89fcca4..eecc17119 100644 --- a/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.spec.ts +++ b/projects/social_platform/src/app/ui/widgets/feed-filter/feed-filter.component.spec.ts @@ -43,5 +43,21 @@ describe("FeedComponent", () => { expect( options.map(option => option.querySelector(".filter__count")?.textContent?.trim()), ).toEqual(["12", "5", "3", "4", "0", "0"]); + for (const option of options) { + expect(option.tagName).toBe("BUTTON"); + expect(Array.from(option.children).map(child => child.className)).toEqual([ + "filter__option--icon", + "filter__title", + "filter__count", + ]); + expect(option.querySelector(".filter__option--icon")?.getAttribute("aria-hidden")).toBe( + "true", + ); + } + expect((options[5] as HTMLButtonElement).disabled).toBe(true); + expect(options[0].getAttribute("aria-pressed")).toBe("true"); + const setFilter = vi.spyOn(component, "setFilter"); + options[1].click(); + expect(setFilter).toHaveBeenCalledExactlyOnceWith("project"); }); }); diff --git a/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.scss b/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.scss index 162f8bb53..cd3db50b1 100644 --- a/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.scss +++ b/projects/social_platform/src/app/ui/widgets/news-card/carousel/carousel.component.scss @@ -6,6 +6,9 @@ align-items: center; justify-content: center; + // Лента задаёт высоту preview; остальные страницы сохраняют естественный размер изображения. + height: var(--carousel-height, auto); + &__image-wrapper { position: relative; width: 100%; @@ -15,6 +18,7 @@ img { display: block; width: 100%; + height: var(--carousel-height, auto); border-radius: var(--rounded-xl); object-fit: cover; } diff --git a/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.html b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.html index 2c7782c11..d346074b9 100644 --- a/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.html +++ b/projects/social_platform/src/app/ui/widgets/news-card/news-card.component.html @@ -1,176 +1,301 @@ -
- @if (feedType()) { -
- - {{ feedType() === "people" ? "Новости людей" : "Новости проекта" }} - - +@if (feedType() && !editMode) { +
+
+ {{ + feedType() === "people" ? "Новости людей" : "Новости проекта" + }} +
- } -
- +
-
-
{{ feedItem().name | truncate: 30 }}
- @if (!feedType()) { -
- {{ feedItem().datetimeCreated | dayjs: "format" : "DD.MM.YYYY" }} -
- } +
- - @if (isOwner()) { -
-
- + @if (isOwner()) { +
+ + @if (menuOpen) { +
    +
  • + +
  • +
  • + +
  • +
+ }
- @if (menuOpen) { -
    - @if (!editMode) { -
  • редактировать
  • - } -
  • удалить
  • -
+ } +
+

+ {{ feedText().headline }} +

+

+ + {{ + feedType() === "people" ? "Перейти в профиль" : "Перейти в проект" + }} +
+} @else { +
+ @if (feedType()) { +
+ + {{ feedType() === "people" ? "Новости людей" : "Новости проекта" }} + +
} -
- @if (feedItem().text) { -
- @if (!editMode) { - @if (feedType()) { -

{{ feedText().headline }}

- @if (feedText().summary) { +
+ + +
+
{{ feedItem().name | truncate: 30 }}
+ @if (!feedType()) { +
+ {{ feedItem().datetimeCreated | dayjs: "format" : "DD.MM.YYYY" }} +
+ } +
+
+ @if (isOwner()) { +
+
+ +
+ @if (menuOpen) { +
    + @if (!editMode) { +
  • редактировать
  • + } +
  • + удалить +
  • +
+ } +
+ } +
+ @if (feedItem().text) { +
+ @if (!editMode) { + @if (feedType()) { +

{{ feedText().headline }}

+ @if (feedText().summary) { +

+ } + } @else {

} } @else { -

+ @if (editForm.get("text"); as text) { + + } } - } @else { - @if (editForm.get("text"); as text) { - +
+ } + @defer (when editMode) { +
    + @for (f of imagesEditList; track f.id) { + + } +
+
    + @for (f of filesEditList; track f.id) { + + } +
+ } + @if (descriptionExpandable() && !editMode && !feedType()) { +
+ {{ readFullDescription() ? "скрыть" : "подробнее" }} +
+ } + @if (!editMode) { + + } + @if (!editMode && filesViewList.length) { +
+ @for (f of filesViewList; track $index) { + + } +
+ } + @if (feedType() && !editMode) { + @if (feedIndustryId(); as industryId) { + @if (industryRepository.getOne(industryId); as industry) { + {{ industry.name }} } } -
- } - @defer (when editMode) { -
    - @for (f of imagesEditList; track f.id) { - - } -
-
    - @for (f of filesEditList; track f.id) { - - } -
- } - @if (descriptionExpandable() && !editMode && !feedType()) { -
- {{ readFullDescription() ? "скрыть" : "подробнее" }} -
- } - @if (!editMode) { - - } - @if (!editMode && filesViewList.length) { -
- @for (f of filesViewList; track $index) { - - } -
- } - @if (feedType() && !editMode) { - @if (feedIndustryId(); as industryId) { - @if (industryRepository.getOne(industryId); as industry) { - {{ industry.name }} - } + + {{ feedType() === "people" ? "Перейти в профиль" : "Перейти в проект" }} + } - - {{ feedType() === "people" ? "Перейти в профиль" : "Перейти в проект" }} - - } - @if (!editMode) { -