diff --git a/docs/qa/program-management-v1/image-framing-20261017.md b/docs/qa/program-management-v1/image-framing-20261017.md new file mode 100644 index 000000000..f68446157 --- /dev/null +++ b/docs/qa/program-management-v1/image-framing-20261017.md @@ -0,0 +1,93 @@ + + +# Настройка кадра программы и мобильная обложка + +## Current / target + +Организатор уже видит draft шапки и логотипа в preview desktop/mobile. Сейчас оба +режима используют один cover, object-fit cover/center; кадр и масштаб не редактируются. +Новый запрос напрямую расширяет существующий preview: изображение внутри кадра +можно двигать и масштабировать, мобильной обложке нужен отдельный файл. + +Сохраняем Mont, DS buttons/fields, существующую форму и preview. Под переключателем +экрана — выбор «Обложка / Логотип», ползунок «Масштаб», кнопки точного сдвига и +«Сбросить кадр». Выбранное изображение можно перетаскивать мышью/пальцем и двигать +клавиатурой. Масштаб 100–300%; область кадра и положение круглого логотипа фиксированы. +Не добавляем отдельный modal или autosave. Это прямо запрошенная локальная итерация +существующего принятого UI; отдельное утверждение нового экрана не требуется. + +Исходные файлы сохраняются. Настройки кадра — отдельно для desktop cover, mobile +cover и общий круг logo. Мобильная обложка необязательна: без неё берём основной +файл, но разрешаем отдельный мобильный кадр. Новая загрузка/удаление сбрасывают +соответствующий кадр. Если заменён основной файл и mobile использует fallback, +сбрасывается и мобильный кадр. Save и cancel сохраняют существующий lifecycle/409. + +## Contract / roles + +- Django PartnerProgram: nullable `mobile_cover_image_address`; JSON `image_framing`, + default `{}`. Новая additive migration0020; legacy выглядит как прежде. +- JSON `{cover:{x:50,y:50,zoom:1},mobile_cover:{...},logo:{...}}`; + x/y — положение в диапазоне0–100, zoom1–3. Сохраняем полный объект либо `{}` для + reset. Строгая валидация формы/ключей, конечные числа, запрет boolean/string/null. +- Existing PATCH `/programs/:id/management/settings/basic/`: оба новых поля optional, + остальной payload/expected_updated_at/atomic lock/permissions unchanged. +- GET management и public/member detail возвращают новые поля. List возвращает + `image_framing`, чтобы logo в списках/сайдбаре имел тот же кадр. Старые клиенты + игнорируют additive поля; frontend tolerates missing fields. +- Только текущие manager/staff по существующему can_manage_program меняют настройки; + anonymous/member/expert/чужой manager не получают write доступ. +- Сначала backend + migration на dev, затем frontend. Production вне scope. + +## Acceptance criteria + +| AC | Наблюдаемый результат | +| --- | -------------------------------------------------------------------------------------------------------------------------------- | +| F1 | Desktop/mobile cover и logo сдвигаются drag/keyboard/buttons, масштабируются100–300%, без пустых краёв и смены размера кадра | +| F2 | Новый mobile файл используется только на телефоне; без него — основной cover с независимым mobile кадром | +| F3 | Draft меняется до Save; после Save/reload реальные header и program logos повторяют crop; cancel восстанавливает сохранённое | +| F4 | Replace/remove/reset корректно сбрасывают кадр, failed upload и409 не теряют draft; mode/target не dirty-ят форму | +| F5 | Server rejects malformed/non-finite/out-of-range/unknown data атомарно; permissions иoptimistic lock действуют для новых полей | +| F6 | Legacy программы и profile/project avatars выглядят как прежде; no horizontal overflow, touch/keyboard usable, busy/error guards | + +## Plan / verification + +1. API model/migration/strict serializer/read contracts; PostgreSQL targeted tests, + public/member/list compatibility, permissions/stale/invalid payload. +2. Shared framing rendering primitive; opt-in Avatar framing, program Detail cover + and all existing program logo consumers. Unit regressions and Angular build. +3. Management draft control + mobile upload + preview drag/range/keyboard/reset; + preserve saving/cancel/guards. DOM integration + geometry tests. +4. Isolated Chrome against real local Django/PostgreSQL; mouse/touch, both sources, + save/reload/cancel/409/failure, actual-vs-preview framing and narrow viewport. +5. Requirement audit/code review; separate dev PRs, backend-first rollout/full CI, + live release smoke. Record real evidence and limitations below. + +## Evidence + +Review **PASS**: блокирующих замечаний нет. Проверены runtime diff, новые файлы, +API compatibility и opt-in Avatar rendering; исходники не переписываются. + +| AC | Evidence | Verdict | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| F1 | Chrome mouse и native CDP touch; keyboard slider/Shift+arrows, unit clamps/reset/busy; кадр fixed/clipped, positions bounded | PASS | +| F2 | Реальный local multipart mobile upload, независимые transform и URL; fallback проверен до/после удаления | PASS | +| F3 | Local API не менялся до Save; Save/reload и точное совпадение computed framing, размеров, natural dimensions, источников с actual header в1440/360; sidebar logo | PASS | +| F4 | DOM upload/reset tests; browser cancel/failed upload413/concurrent PATCH409 без потери draft; toggles pristine | PASS | +| F5 | 48 API tests на local PostgreSQL, включая5 новых; invalid/finite/range/huge int/roles/stale/atomic update; final framing5PASS | PASS | +| F6 | Legacy Avatar/profile/project tests и source diff; mobile360/390 оба режима без overflow; page errors0; новые controls44px | PASS | + +- Angular targeted tests/build/ESLint/Stylelint/format: результаты и full CI в PR. +- Local Django migration0020 применена; makemigrations --check — no changes. + -7 browser scenarios PASS на реальных local Django/PostgreSQL. Только внешний + object storage заменён local adapter. Один harness retry: native output также + имеет role=status, поэтому ожидание upload уточнено до p[role=status]. +- Новый hint использует существующий token black для читаемого текста; Mont, + button foundation, spacing/radius и существующий preview сохранены. + +[Desktop](image-framing-20261017/desktop.png), [mobile](image-framing-20261017/mobile.png), +[geometry/browser evidence](image-framing-20261017/browser-qa.json). + +Not checked: физический телефон, OS screen reader, Selectel upload transport, +live organizer session. Native touch input проверен через Chrome CDP. +Backend PR [782](https://github.com/PROCOLLAB-github/api/pull/782); только dev rollout, +сначала API/migration, затем Angular. Live release checks будут в PR после deployment. diff --git a/docs/qa/program-management-v1/image-framing-20261017/browser-qa.json b/docs/qa/program-management-v1/image-framing-20261017/browser-qa.json new file mode 100644 index 000000000..c246ebafc --- /dev/null +++ b/docs/qa/program-management-v1/image-framing-20261017/browser-qa.json @@ -0,0 +1,64 @@ +{ + "checks": [ + "Mode/target toggles keep form pristine; mobile uploader is optional and visible", + "Mouse drag and keyboard slider edit cover/logo draft without persisting program", + "Mobile fallback has independent crop; separate mobile upload resets only mobile crop and leaves desktop/logo unchanged", + "Save/reload roundtrip and real desktop/mobile header crop match preview exactly; sidebar logo uses saved framing", + "Cancel and failed upload preserve correct frame; real concurrent update returns409 without losing unsaved crop", + "Native touch drag, keyboard arrows and controls work at360/390px; both reference modes have no horizontal overflow", + "Removing mobile returns to main cover/default crop; removing all covers disables editing; cancel restores saved sources" + ], + "errors": [], + "blockedExternal": [ + "gc.kis.v2.scr.kaspersky-labs.com", + "fonts.googleapis.com", + "gc.kis.v2.scr.kaspersky-labs.com", + "fonts.googleapis.com", + "gc.kis.v2.scr.kaspersky-labs.com", + "fonts.googleapis.com" + ], + "geometry": [ + { + "device": "desktop", + "cover": { + "url": "https://example.com/preview-cover.svg", + "position": "60.68% 30.17%", + "origin": "525.887px 46.7635px", + "transform": "matrix(1.65, 0, 0, 1.65, 0, 0)", + "natural": [1800, 320], + "width": 867, + "height": 155 + }, + "logo": { + "url": "https://example.com/preview-logo.svg", + "position": "55.5% 35.33%", + "origin": "67.155px 42.7493px", + "transform": "matrix(1.5, 0, 0, 1.5, 0, 0)", + "natural": [600, 300], + "width": 121, + "height": 121 + } + }, + { + "device": "mobile", + "cover": { + "url": "https://example.com/local-material-mobile-1792253248780.svg", + "position": "68.29% 21.29%", + "origin": "223.991px 28.9544px", + "transform": "matrix(1.25, 0, 0, 1.25, 0, 0)", + "natural": [960, 400], + "width": 328, + "height": 136 + }, + "logo": { + "url": "https://example.com/preview-logo.svg", + "position": "55.5% 35.33%", + "origin": "57.165px 36.3899px", + "transform": "matrix(1.5, 0, 0, 1.5, 0, 0)", + "natural": [600, 300], + "width": 103, + "height": 103 + } + } + ] +} diff --git a/docs/qa/program-management-v1/image-framing-20261017/desktop.png b/docs/qa/program-management-v1/image-framing-20261017/desktop.png new file mode 100644 index 000000000..58d6517ab Binary files /dev/null and b/docs/qa/program-management-v1/image-framing-20261017/desktop.png differ diff --git a/docs/qa/program-management-v1/image-framing-20261017/mobile.png b/docs/qa/program-management-v1/image-framing-20261017/mobile.png new file mode 100644 index 000000000..235665fb8 Binary files /dev/null and b/docs/qa/program-management-v1/image-framing-20261017/mobile.png differ diff --git a/projects/social_platform/src/app/domain/program/program-image-framing.model.ts b/projects/social_platform/src/app/domain/program/program-image-framing.model.ts new file mode 100644 index 000000000..4112195c6 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-image-framing.model.ts @@ -0,0 +1,9 @@ +/** @format */ + +export interface ProgramImageFrame { + x: number; + y: number; + zoom: number; +} +export type ProgramImageKey = "cover" | "mobileCover" | "logo"; +export type ProgramImageFraming = Partial>; diff --git a/projects/social_platform/src/app/domain/program/program-management.model.ts b/projects/social_platform/src/app/domain/program/program-management.model.ts index a8c060d0d..fdb9d2108 100644 --- a/projects/social_platform/src/app/domain/program/program-management.model.ts +++ b/projects/social_platform/src/app/domain/program/program-management.model.ts @@ -1,5 +1,7 @@ /** @format */ +import { ProgramImageFraming } from "./program-image-framing.model"; + export type ManagementSection = | "participants" | "projects" @@ -21,6 +23,8 @@ export interface ManagementBasic { description: string | null; imageAddress: string | null; coverImageAddress: string | null; + mobileCoverImageAddress?: string | null; + imageFraming?: ProgramImageFraming; advertisementImageAddress: string | null; registrationLink: string | null; presentationAddress: string | null; @@ -112,7 +116,7 @@ export interface ManagementQuery { export interface ManagementSettingsWrite { expectedUpdatedAt: string; confirmReopen?: boolean; - [field: string]: string | boolean | null | undefined; + [field: string]: string | boolean | null | undefined | ProgramImageFraming; } export interface ManagementMaterialWrite { title: string; diff --git a/projects/social_platform/src/app/domain/program/program.model.ts b/projects/social_platform/src/app/domain/program/program.model.ts index e62c06603..499255db2 100644 --- a/projects/social_platform/src/app/domain/program/program.model.ts +++ b/projects/social_platform/src/app/domain/program/program.model.ts @@ -1,5 +1,7 @@ /** @format */ +import { ProgramImageFraming } from "./program-image-framing.model"; + export interface ProgramCurrentApplication { projectId: number; programLinkId: number; @@ -11,6 +13,8 @@ export class Program { id!: number; imageAddress!: string; coverImageAddress!: string; + mobileCoverImageAddress?: string | null; + imageFraming?: ProgramImageFraming; presentationAddress!: string; advertisementImageAddress!: string; name!: string; diff --git a/projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.html b/projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.html index a6ac4e94e..643fbedf5 100644 --- a/projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.html +++ b/projects/social_platform/src/app/ui/pages/office/program-sidebar-card/program-sidebar-card.component.html @@ -3,7 +3,11 @@
- +

до {{ program().datetimeRegistrationEnds | date: "dd.MM.yy" }}

diff --git a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html index 826edca5b..491f35d5c 100644 --- a/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html +++ b/projects/social_platform/src/app/ui/pages/program/main/program-card/program-card.component.html @@ -4,7 +4,11 @@ @if (program()) {
- +
@@ -47,7 +51,11 @@ @if (program()) {
- +
diff --git a/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.html b/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.html index aaca8fa04..e4a02674e 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.html @@ -27,8 +27,27 @@

Предпросмотр шапки и лого

- Так будут выглядеть выбранные изображения. Изменения появятся на странице после сохранения. + Выберите изображение, переместите его в кадре и настройте масштаб. Изменения появятся на + странице после сохранения.

+
+ + +
Предпросмотр шапки и лого [style.--logo-size]="logoSize() + 'px'" >
- Обложка программы в предпросмотре - +
+ Обложка программы в предпросмотре +
+
+ +
+
+ +
+ + + + +
+ +
+

+ Двигайте изображение мышью или пальцем. Когда кадр в фокусе, используйте стрелки клавиатуры; + Shift — для большего сдвига. +

+ @if (device() === "mobile" && !mobileCover()) { +

+ Сейчас используется основная обложка. Можно загрузить отдельную обложку для телефона выше. +

+ }

{{ device() === "desktop" ? "Пример для экрана 1440 px" : "Пример для экрана 360 px" }}. Края обложки за пределами шапки обрезаются, логотип отображается в круге. diff --git a/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.scss b/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.scss index 1039cb92a..da45ae228 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.scss @@ -44,7 +44,7 @@ } &__hint { - color: var(--dark-grey); + color: var(--black); } &__viewport { @@ -80,20 +80,88 @@ border-radius: inherit; } - &__logo { + &__cover-frame { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: inherit; + } + + &__logo-frame { @include program-branding.desktop-logo; position: absolute; box-sizing: border-box; width: var(--logo-size); height: var(--logo-size); - object-fit: cover; - object-position: center; + overflow: hidden; background: var(--white); border: 0.5px solid var(--dark-grey); border-radius: 50%; } + &__logo { + display: block; + width: 100%; + height: 100%; + } + + &__frame--editable { + touch-action: none; + cursor: grab; + user-select: none; + + &:active { + cursor: grabbing; + } + + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + } + } + + &__frame--active { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + &__controls { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; + } + + &__zoom { + display: flex; + flex: 1 1 240px; + gap: 12px; + align-items: center; + + input { + flex: 1; + min-width: 60px; + min-height: 44px; + accent-color: var(--accent); + } + + output { + min-width: 40px; + } + } + + &__move { + display: flex; + gap: 8px; + + button { + min-width: 44px; + padding: 8px; + font-size: 18px; + } + } + &__canvas--mobile { padding-bottom: 32px; @@ -101,7 +169,7 @@ @include program-branding.mobile-cover; } - .branding-preview__logo { + .branding-preview__logo-frame { @include program-branding.mobile-logo; } } diff --git a/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.spec.ts index d649adc1f..5ad0dcc59 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.spec.ts @@ -31,7 +31,9 @@ describe("Предпросмотр оформления программы", () it("режим экрана доступен кнопками и сохраняется при изменении изображений", () => { const { fixture, element } = setup(); - const buttons = element.querySelectorAll("button"); + const buttons = element.querySelectorAll( + '[aria-label="Экран предпросмотра"] button', + ); buttons[1].click(); fixture.detectChanges(); expect(buttons[0].getAttribute("aria-pressed")).toBe("false"); @@ -47,6 +49,93 @@ describe("Предпросмотр оформления программы", () expect(element.textContent).toContain("экрана 1440 px"); }); + it("отдельный mobile source и frame не меняют desktop; общий logo редактируется независимо", () => { + const { fixture, element, cover, logo } = setup(); + fixture.componentInstance.device.set("desktop"); + fixture.componentRef.setInput("cover", "https://example.com/desktop.png"); + fixture.componentRef.setInput("mobileCover", "https://example.com/mobile.png"); + fixture.componentRef.setInput("logo", "https://example.com/logo.png"); + fixture.componentInstance.framingChange.subscribe(value => + fixture.componentRef.setInput("framing", value), + ); + fixture.detectChanges(); + const slider = element.querySelector('input[type="range"]')!; + slider.value = "1.8"; + slider.dispatchEvent(new Event("input", { bubbles: true })); + fixture.detectChanges(); + expect(cover().style.transform).toBe("scale(1.8)"); + fixture.componentInstance.device.set("mobile"); + fixture.detectChanges(); + expect(cover().src).toBe("https://example.com/mobile.png"); + expect(slider.value).toBe("1"); + element.querySelector(".branding-preview__cover-frame")!.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowLeft", + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + fixture.detectChanges(); + expect(cover().style.objectPosition).toBe("60% 50%"); + fixture.componentInstance.target.set("logo"); + fixture.componentInstance.move(-10, 20); + fixture.detectChanges(); + expect(logo().style.objectPosition).toBe("40% 70%"); + expect(fixture.componentInstance.frames().cover.zoom).toBe(1.8); + fixture.componentRef.setInput("mobileCover", null); + fixture.detectChanges(); + expect(cover().src).toBe("https://example.com/desktop.png"); + expect(cover().style.objectPosition).toBe("60% 50%"); + }); + + it("drag учитывает zoom, ограничивает края, busy блокирует правки; reset не меняет остальные кадры", () => { + const { fixture, element, cover } = setup(); + const component = fixture.componentInstance; + component.device.set("desktop"); + fixture.componentRef.setInput("cover", "https://example.com/cover.png"); + fixture.componentRef.setInput("framing", { cover: { x: 50, y: 50, zoom: 2 } }); + const changed = vi.fn(value => fixture.componentRef.setInput("framing", value)); + component.framingChange.subscribe(changed); + fixture.detectChanges(); + const frame = element.querySelector(".branding-preview__cover-frame")!; + Object.defineProperties(frame, { clientWidth: { value: 400 }, clientHeight: { value: 100 } }); + Object.defineProperties(cover(), { + naturalWidth: { value: 1000 }, + naturalHeight: { value: 500 }, + }); + function pointer(type: string, x: number, y: number) { + const event = new MouseEvent(type, { + clientX: x, + clientY: y, + button: 0, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, "pointerId", { value: 1 }); + frame.dispatchEvent(event); + fixture.detectChanges(); + } + pointer("pointerdown", 100, 50); + pointer("pointermove", 150, 80); + expect(component.frames().cover).toEqual({ x: 37.5, y: 40, zoom: 2 }); + pointer("pointermove", 9999, -9999); + expect(component.frames().cover).toEqual({ x: 0, y: 100, zoom: 2 }); + pointer("pointerup", 9999, -9999); + fixture.componentRef.setInput("busy", true); + fixture.detectChanges(); + changed.mockClear(); + component.move(2, 2); + component.resetFrame(); + expect(changed).not.toHaveBeenCalled(); + fixture.componentRef.setInput("busy", false); + fixture.detectChanges(); + component.resetFrame(); + fixture.detectChanges(); + expect(component.frames().cover).toEqual({ x: 50, y: 50, zoom: 1 }); + expect(component.frames().mobileCover).toEqual({ x: 50, y: 50, zoom: 1 }); + }); + it("показывает busy и ошибку изображения, не изменяя URL; замена очищает ошибку", () => { const { fixture, element, cover } = setup(); fixture.componentRef.setInput("cover", "https://example.com/broken.png"); diff --git a/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.ts b/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.ts index 974d04765..20c8123a5 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/branding-preview/branding-preview.component.ts @@ -10,15 +10,26 @@ import { ElementRef, inject, input, + output, signal, viewChild, } from "@angular/core"; -import { AVATAR_PLACEHOLDER_URL, ButtonDirective } from "@uilib"; -import { PROGRAM_DEFAULT_COVER, PROGRAM_LOGO_SIZES } from "@utils/program-branding"; +import { AVATAR_PLACEHOLDER_URL, ButtonDirective, ImageFramingDirective } from "@uilib"; +import { + DEFAULT_IMAGE_FRAME, + PROGRAM_DEFAULT_COVER, + PROGRAM_LOGO_SIZES, + programImageFraming, +} from "@utils/program-branding"; +import { + ProgramImageFrame, + ProgramImageFraming, + ProgramImageKey, +} from "@domain/program/program-image-framing.model"; @Component({ selector: "app-program-branding-preview", - imports: [ButtonDirective], + imports: [ButtonDirective, ImageFramingDirective], templateUrl: "./branding-preview.component.html", styleUrl: "./branding-preview.component.scss", changeDetection: ChangeDetectionStrategy.OnPush, @@ -26,9 +37,26 @@ import { PROGRAM_DEFAULT_COVER, PROGRAM_LOGO_SIZES } from "@utils/program-brandi export class ProgramBrandingPreviewComponent { readonly cover = input(null); readonly logo = input(null); + readonly mobileCover = input(null); + readonly framing = input(null); + readonly disabled = input(false); + readonly framingChange = output(); readonly busy = input(false); readonly device = signal<"desktop" | "mobile">(window.innerWidth < 1000 ? "mobile" : "desktop"); - readonly coverUrl = computed(() => this.cover() || PROGRAM_DEFAULT_COVER); + readonly target = signal<"cover" | "logo">("cover"); + readonly frames = computed(() => programImageFraming(this.framing())); + readonly coverKey = computed(() => + this.device() === "mobile" ? "mobileCover" : "cover", + ); + readonly activeKey = computed(() => + this.target() === "logo" ? "logo" : this.coverKey(), + ); + readonly activeFrame = computed(() => this.frames()[this.activeKey()]); + readonly zoomPercent = computed(() => Math.round(this.activeFrame().zoom * 100)); + readonly selectedCover = computed( + () => (this.device() === "mobile" && this.mobileCover()) || this.cover(), + ); + readonly coverUrl = computed(() => this.selectedCover() || PROGRAM_DEFAULT_COVER); readonly logoUrl = computed(() => this.logo() || AVATAR_PLACEHOLDER_URL); readonly coverFailed = signal(false); readonly logoFailed = signal(false); @@ -46,8 +74,110 @@ export class ProgramBrandingPreviewComponent { private readonly viewport = viewChild.required>("viewport"); private readonly canvas = viewChild.required>("canvas"); private readonly destroyRef = inject(DestroyRef); + private drag: { + id: number; + element: HTMLElement; + key: ProgramImageKey; + x: number; + y: number; + frame: ProgramImageFrame; + overflowX: number; + overflowY: number; + } | null = null; + + available(target: "cover" | "logo"): boolean { + return ( + !this.disabled() && + !this.busy() && + !!(target === "logo" ? this.logo() : this.selectedCover()) && + !(target === "logo" ? this.logoFailed() : this.coverFailed()) + ); + } + editable(): boolean { + return this.available(this.target()); + } + private updateFrame(key: ProgramImageKey, frame: ProgramImageFrame): void { + if (!this.editable()) return; + const next = { + x: Math.round(Math.min(100, Math.max(0, frame.x)) * 100) / 100, + y: Math.round(Math.min(100, Math.max(0, frame.y)) * 100) / 100, + zoom: Math.round(Math.min(3, Math.max(1, frame.zoom)) * 100) / 100, + }; + const previous = this.frames()[key]; + if (next.x === previous.x && next.y === previous.y && next.zoom === previous.zoom) return; + this.framingChange.emit({ ...this.frames(), [key]: next }); + } + setZoom(event: Event): void { + const zoom = Number((event.target as HTMLInputElement).value); + if (Number.isFinite(zoom)) this.updateFrame(this.activeKey(), { ...this.activeFrame(), zoom }); + } + move(x: number, y: number): void { + const frame = this.activeFrame(); + this.updateFrame(this.activeKey(), { ...frame, x: frame.x + x, y: frame.y + y }); + } + resetFrame(): void { + this.updateFrame(this.activeKey(), { ...DEFAULT_IMAGE_FRAME }); + } + onFrameKey(event: KeyboardEvent, target: "cover" | "logo"): void { + if (!this.available(target)) return; + const direction: Record = { + ArrowLeft: [1, 0], + ArrowRight: [-1, 0], + ArrowUp: [0, 1], + ArrowDown: [0, -1], + }; + const delta = direction[event.key]; + if (!delta) return; + event.preventDefault(); + this.target.set(target); + const step = event.shiftKey ? 10 : 2; + this.move(delta[0] * step, delta[1] * step); + } + startDrag(event: PointerEvent, target: "cover" | "logo"): void { + if (event.button !== 0 || event.isPrimary === false || !this.available(target)) return; + this.target.set(target); + const element = event.currentTarget as HTMLElement; + const img = element.querySelector("img"); + if (!img?.naturalWidth || !img.naturalHeight) return; + const frame = this.activeFrame(); + const imageScale = + Math.max(element.clientWidth / img.naturalWidth, element.clientHeight / img.naturalHeight) * + frame.zoom; + this.drag = { + id: event.pointerId, + element, + key: this.activeKey(), + x: event.clientX, + y: event.clientY, + frame, + overflowX: Math.max(0, img.naturalWidth * imageScale - element.clientWidth) * this.scale(), + overflowY: Math.max(0, img.naturalHeight * imageScale - element.clientHeight) * this.scale(), + }; + event.preventDefault(); + element.focus({ preventScroll: true }); + element.setPointerCapture?.(event.pointerId); + } + moveDrag(event: PointerEvent): void { + const drag = this.drag; + if (!drag || drag.id !== event.pointerId || drag.key !== this.activeKey() || !this.editable()) + return; + event.preventDefault(); + this.updateFrame(drag.key, { + ...drag.frame, + x: + drag.frame.x - (drag.overflowX > 0 ? ((event.clientX - drag.x) / drag.overflowX) * 100 : 0), + y: + drag.frame.y - (drag.overflowY > 0 ? ((event.clientY - drag.y) / drag.overflowY) * 100 : 0), + }); + } + stopDrag(): void { + const drag = this.drag; + this.drag = null; + if (drag?.element.hasPointerCapture?.(drag.id)) drag.element.releasePointerCapture(drag.id); + } constructor() { + this.destroyRef.onDestroy(() => this.stopDrag()); effect(() => { this.coverUrl(); this.coverFailed.set(false); diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.html b/projects/social_platform/src/app/ui/pages/program/management/management.component.html index ed22491af..1d646bd5f 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.html @@ -441,10 +441,7 @@

Изображения

type="button" appButton="text" [disabled]="!!uploading() || saving()" - (click)=" - basicForm.get(image.key)?.setValue(''); - basicForm.get(image.key)?.markAsDirty() - " + (click)="clearImage(image.key)" > Убрать @@ -455,7 +452,11 @@

Изображения

} } diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts index b0900fcf8..45ced320e 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.spec.ts @@ -103,6 +103,36 @@ describe("Сохранение настроек организатора", () => expect(component.basicForm.dirty).toBe(true); expect(component.formError()).toContain("Данные изменились"); }); + it("сохраняет mobile URL и полный framing; 409 оставляет кадр для повторной попытки", () => { + const { component, save } = setup(); + component.settingsTab.set("basic"); + const framing = { + cover: { x: 20, y: 70, zoom: 2 }, + mobileCover: { x: 80, y: 30, zoom: 1.5 }, + logo: { x: 50, y: 50, zoom: 1 }, + }; + component.changeFraming(framing); + component.basicForm.controls.mobileCoverImageAddress.setValue("https://example.com/mobile.png"); + component.basicForm.controls.mobileCoverImageAddress.markAsDirty(); + save.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 409, + error: { code: "stale_settings", detail: "Кадр изменён другим организатором" }, + }), + ), + ); + component.save(); + expect(save).toHaveBeenCalledWith(1, "basic", { + expectedUpdatedAt: program.updatedAt, + confirmReopen: false, + mobileCoverImageAddress: "https://example.com/mobile.png", + imageFraming: framing, + }); + expect(component.basicForm.controls.imageFraming.value).toEqual(framing); + expect(component.basicForm.dirty).toBe(true); + }); }); describe("Списки управления программой в DOM", () => { @@ -188,6 +218,49 @@ describe("Списки управления программой в DOM", () => return event as DragEvent; } + it("замена и удаление изображений сбрасывают только зависимые кадры, включая mobile fallback", async () => { + const { component, harness, element, uploaded } = await setup("settings", "?tab=basic"); + const framing = { + cover: { x: 20, y: 30, zoom: 2 }, + mobileCover: { x: 80, y: 70, zoom: 3 }, + logo: { x: 40, y: 60, zoom: 1.5 }, + }; + component.basicForm.controls.mobileCoverImageAddress.setValue("https://example.com/mobile.png"); + component.changeFraming(framing); + const input = element().querySelector( + 'input[type=file][aria-describedby="program-image-hint-coverImageAddress"]', + )!; + Object.defineProperty(input, "files", { + value: [new File(["image"], "cover.png", { type: "image/png" })], + }); + input.dispatchEvent(new Event("change", { bubbles: true })); + uploaded.next({ url: "https://example.com/cover.png" }); + uploaded.complete(); + harness.detectChanges(); + expect(component.basicForm.controls.imageFraming.value).toEqual({ + ...framing, + cover: { x: 50, y: 50, zoom: 1 }, + }); + component.clearImage("mobileCoverImageAddress"); + expect(component.basicForm.controls.imageFraming.value.mobileCover).toEqual({ + x: 50, + y: 50, + zoom: 1, + }); + component.changeFraming(framing); + component.clearImage("coverImageAddress"); + expect(component.basicForm.controls.imageFraming.value).toEqual({ + ...framing, + cover: { x: 50, y: 50, zoom: 1 }, + mobileCover: { x: 50, y: 50, zoom: 1 }, + }); + component.clearImage("imageAddress"); + expect(component.basicForm.controls.imageFraming.value.logo).toEqual({ x: 50, y: 50, zoom: 1 }); + component.resetForms(); + expect(component.basicForm.pristine).toBe(true); + expect(component.basicForm.controls.mobileCoverImageAddress.value).toBe(""); + }); + it("предпросмотр использует несохранённую загрузку; переключение не сохраняет и не меняет dirty", async () => { const { component, harness, element, state, uploaded } = await setup("settings", "?tab=basic"); const save = vi.spyOn(component, "save"); diff --git a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts index 8650531b1..ccb592ba9 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/management.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/management.component.ts @@ -51,6 +51,8 @@ import { ProgramAnalyticsComponent } from "../detail/analytics/analytics.compone import { ProgramBrandingPreviewComponent } from "./branding-preview/branding-preview.component"; import { presentationHref } from "@utils/program-case-analytics"; import { evaluationLabel, fromMoscowInput, toMoscowInput } from "@utils/program-management"; +import { DEFAULT_IMAGE_FRAME, programImageFraming } from "@utils/program-branding"; +import { ProgramImageFraming, ProgramImageKey } from "@domain/program/program-image-framing.model"; @Component({ selector: "app-program-management", @@ -136,6 +138,8 @@ export class ProgramManagementComponent implements OnInit { description: [""], imageAddress: [""], coverImageAddress: [""], + mobileCoverImageAddress: [""], + imageFraming: this.fb.control(programImageFraming()), advertisementImageAddress: [""], registrationLink: [""], presentationAddress: [""], @@ -165,6 +169,12 @@ export class ProgramManagementComponent implements OnInit { dimensions: "Рекомендуемый размер: 1800 × 320 px (примерно 5,6:1).", hint: "Размещайте важные детали вверху по центру: на узком экране края обрезаются, а снизу расположен логотип.", }, + { + key: "mobileCoverImageAddress", + label: "Обложка для телефона", + dimensions: "Рекомендуемый размер: 960 × 400 px (примерно 2,4:1).", + hint: "Необязательно. Без отдельного файла используется основная обложка. Кадр для телефона можно настроить независимо.", + }, { key: "imageAddress", label: "Логотип программы", @@ -379,6 +389,8 @@ export class ProgramManagementComponent implements OnInit { description: p.description ?? "", imageAddress: p.imageAddress ?? "", coverImageAddress: p.coverImageAddress ?? "", + mobileCoverImageAddress: p.mobileCoverImageAddress ?? "", + imageFraming: programImageFraming(p.imageFraming), advertisementImageAddress: p.advertisementImageAddress ?? "", registrationLink: p.registrationLink ?? "", presentationAddress: p.presentationAddress ?? "", @@ -404,7 +416,10 @@ export class ProgramManagementComponent implements OnInit { const body: ManagementSettingsWrite = { expectedUpdatedAt: p.updatedAt, confirmReopen }; for (const [name, control] of Object.entries(form.controls)) if (control.dirty) - body[name] = section === "dates" ? fromMoscowInput(control.value) : control.value; + body[name] = + section === "dates" && typeof control.value === "string" + ? fromMoscowInput(control.value) + : control.value; this.saving.set(true); this.formError.set(null); this.saved.set(false); @@ -466,6 +481,7 @@ export class ProgramManagementComponent implements OnInit { target === "material" ? this.materialForm.controls.file : this.basicForm.get(target); control?.setValue(url); control?.markAsDirty(); + if (target !== "material") this.resetFrameForImage(target); if (target === "material") this.materialFileName.set(file.name); }, error: (error: HttpErrorResponse) => @@ -476,6 +492,33 @@ export class ProgramManagementComponent implements OnInit { ), }); } + clearImage(target: string): void { + if (this.saving() || this.uploading()) return; + const control = this.basicForm.get(target); + control?.setValue(""); + control?.markAsDirty(); + this.resetFrameForImage(target); + } + changeFraming(value: ProgramImageFraming): void { + if (this.saving() || this.uploading()) return; + this.basicForm.controls.imageFraming.setValue(value); + this.basicForm.controls.imageFraming.markAsDirty(); + } + private resetFrameForImage(target: string): void { + const keys: Record = { + imageAddress: "logo", + coverImageAddress: "cover", + mobileCoverImageAddress: "mobileCover", + }; + const key = keys[target]; + if (!key) return; + const framing = programImageFraming(this.basicForm.controls.imageFraming.value); + framing[key] = { ...DEFAULT_IMAGE_FRAME }; + if (key === "cover" && !this.basicForm.controls.mobileCoverImageAddress.value) + framing.mobileCover = { ...DEFAULT_IMAGE_FRAME }; + this.basicForm.controls.imageFraming.setValue(framing); + this.basicForm.controls.imageFraming.markAsDirty(); + } onMaterialDragEnter(event: DragEvent): void { if (!event.dataTransfer?.types.includes("Files")) return; event.preventDefault(); diff --git a/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.html b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.html index 803a1e35e..dcfe9f414 100644 --- a/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.html +++ b/projects/social_platform/src/app/ui/primitives/avatar/avatar.component.html @@ -1,6 +1,6 @@ @if (desktopLayout()) { -
+
@if (progress()) {
} - avatar + @if (imageFraming()) { +
+ avatar +
+ } @else { + avatar + } @if (isOnline()) {
} - avatar + @if (imageFraming()) { +
+ avatar +
+ } @else { + avatar + } @if (isOnline()) {
- cover + @if (listType() === "program") { +
+ Обложка программы +
+ } @else { + cover + }
@if (chatStateService.userOnlineStatusCache | async; as cache) { = { x: 50, y: 50, zoom: 1 }; + +export function programImageFraming( + value?: ProgramImageFraming | null, +): Record { + return { + cover: { ...DEFAULT_IMAGE_FRAME, ...value?.cover }, + mobileCover: { ...DEFAULT_IMAGE_FRAME, ...value?.mobileCover }, + logo: { ...DEFAULT_IMAGE_FRAME, ...value?.logo }, + }; +} diff --git a/projects/ui/src/lib/components/primitives/avatar/avatar.component.html b/projects/ui/src/lib/components/primitives/avatar/avatar.component.html index f2e14617c..07453c3cb 100644 --- a/projects/ui/src/lib/components/primitives/avatar/avatar.component.html +++ b/projects/ui/src/lib/components/primitives/avatar/avatar.component.html @@ -2,6 +2,7 @@
} - avatar + @if (imageFraming()) { +
+ avatar +
+ } @else { + avatar + } @if (isOnline()) {
{ }).compileComponents(); }); + it("framing is opt-in and keeps circle border, online badge and progress outside the clipped image", () => { + fixture = TestBed.createComponent(AvatarComponent); + fixture.componentRef.setInput("url", "https://example.com/logo.png"); + fixture.componentRef.setInput("imageFraming", { x: 10, y: 70, zoom: 2 }); + fixture.componentRef.setInput("hasBorder", true); + fixture.componentRef.setInput("isOnline", true); + fixture.componentRef.setInput("progress", 80); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + const crop = element.querySelector(".avatar__crop")!; + expect((crop.querySelector("img") as HTMLImageElement).style.transform).toBe("scale(2)"); + expect(element.querySelector(".avatar__online")).toBeTruthy(); + expect(element.querySelector(".avatar__progress")).toBeTruthy(); + expect(crop.querySelector(".avatar__online, .avatar__progress")).toBeNull(); + fixture.componentRef.setInput("imageFraming", null); + fixture.detectChanges(); + expect(element.querySelector(".avatar__crop")).toBeNull(); + expect((element.querySelector("img") as HTMLImageElement).style.transform).toBe(""); + }); + it("should create", () => { fixture = TestBed.createComponent(AvatarComponent); component = fixture.componentInstance; diff --git a/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts b/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts index d5c570dcb..d0ca7fa7e 100644 --- a/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts +++ b/projects/ui/src/lib/components/primitives/avatar/avatar.component.ts @@ -1,6 +1,7 @@ /** @format */ import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { ImageFraming, ImageFramingDirective } from "../image-framing/image-framing.directive"; export const AVATAR_PLACEHOLDER_URL = "https://hwchamber.co.uk/wp-content/uploads/2022/04/avatar-placeholder.gif"; @@ -29,9 +30,12 @@ export const AVATAR_PLACEHOLDER_URL = templateUrl: "./avatar.component.html", styleUrl: "./avatar.component.scss", standalone: true, + imports: [ImageFramingDirective], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AvatarComponent { + /** Optional framing; existing avatars keep their original rendering. */ + readonly imageFraming = input(null); /** URL изображения аватара */ url = input.required(); diff --git a/projects/ui/src/lib/components/primitives/image-framing/image-framing.directive.spec.ts b/projects/ui/src/lib/components/primitives/image-framing/image-framing.directive.spec.ts new file mode 100644 index 000000000..98d19f223 --- /dev/null +++ b/projects/ui/src/lib/components/primitives/image-framing/image-framing.directive.spec.ts @@ -0,0 +1,32 @@ +/** @format */ + +import { Component, signal } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { ImageFraming, ImageFramingDirective } from "./image-framing.directive"; + +@Component({ + imports: [ImageFramingDirective], + template: 'test', +}) +class Host { + readonly frame = signal | null>(null); +} + +describe("Shared image framing", () => { + it("center defaults and matching origin keep the chosen crop when zooming", () => { + const fixture = TestBed.createComponent(Host); + fixture.detectChanges(); + const image = fixture.nativeElement.querySelector("img") as HTMLImageElement; + expect(image.style.objectPosition).toBe("50% 50%"); + expect(image.style.transform).toBe("scale(1)"); + fixture.componentInstance.frame.set({ x: 20, y: 80, zoom: 2 }); + fixture.detectChanges(); + expect(image.style.objectPosition).toBe("20% 80%"); + expect(image.style.transformOrigin).toBe("20% 80%"); + expect(image.style.transform).toBe("scale(2)"); + fixture.componentInstance.frame.set({ x: -500, y: NaN, zoom: Infinity }); + fixture.detectChanges(); + expect(image.style.objectPosition).toBe("0% 50%"); + expect(image.style.transform).toBe("scale(1)"); + }); +}); diff --git a/projects/ui/src/lib/components/primitives/image-framing/image-framing.directive.ts b/projects/ui/src/lib/components/primitives/image-framing/image-framing.directive.ts new file mode 100644 index 000000000..25c9c0604 --- /dev/null +++ b/projects/ui/src/lib/components/primitives/image-framing/image-framing.directive.ts @@ -0,0 +1,33 @@ +/** @format */ + +import { computed, Directive, input } from "@angular/core"; + +export interface ImageFraming { + x: number; + y: number; + zoom: number; +} + +/** The parent clips the frame; the original image remains untouched. */ +@Directive({ + selector: "img[appImageFraming]", + host: { + "[style.object-fit]": "'cover'", + "[style.object-position]": "position()", + "[style.transform-origin]": "position()", + "[style.transform]": "'scale(' + zoom() + ')'", + }, +}) +export class ImageFramingDirective { + readonly appImageFraming = input | null>(); + private number(value: unknown, fallback: number, min: number, max: number): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.min(max, Math.max(min, value)) + : fallback; + } + readonly position = computed(() => { + const frame = this.appImageFraming(); + return `${this.number(frame?.x, 50, 0, 100)}% ${this.number(frame?.y, 50, 0, 100)}%`; + }); + readonly zoom = computed(() => this.number(this.appImageFraming()?.zoom, 1, 1, 3)); +} diff --git a/projects/ui/src/lib/components/primitives/index.ts b/projects/ui/src/lib/components/primitives/index.ts index 8195c91ab..3f8a3f148 100644 --- a/projects/ui/src/lib/components/primitives/index.ts +++ b/projects/ui/src/lib/components/primitives/index.ts @@ -13,6 +13,7 @@ */ export * from "./avatar/avatar.component"; +export * from "./image-framing/image-framing.directive"; export * from "./back/back.component"; export * from "./button/button.component"; export * from "./icon/icon.component";