• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / Iznik / 11495

09 May 2026 07:35AM UTC coverage: 69.06% (-3.8%) from 72.847%
11495

Pull #408

circleci

edwh
docs(migration): mark restartproject and repaircafewales as migrated (PR #408)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull Request #408: feat(batch): migrate check_cgas, visualise, tn_sync + dry-run improvements

9127 of 10554 branches covered (86.48%)

Branch coverage included in aggregate %.

507 of 663 new or added lines in 16 files covered. (76.47%)

11902 existing lines in 138 files now uncovered.

101630 of 149824 relevant lines covered (67.83%)

19.56 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.1
/iznik-nuxt3/components/MessageExpanded.vue
1
<template>
1✔
2
  <div
1✔
3
    v-if="message"
1✔
4
    class="message-expanded-wrapper"
1✔
5
    :class="{ 'in-modal': inModal, 'fullscreen-overlay': fullscreenOverlay }"
1✔
6
  >
7
    <div
1✔
8
      ref="containerRef"
1✔
9
      class="message-expanded-mobile"
1✔
10
      :class="{ stickyAdRendered }"
1✔
11
    >
12
      <!-- Hide the default navbar by teleporting an empty replacement (only in fullscreen overlay mode) -->
1✔
13
      <Teleport
1✔
14
        v-if="navbarMobileExists && fullscreenOverlay"
1!
15
        to="#navbar-mobile"
1✔
16
      >
17
        <div class="hidden-navbar" />
1✔
18
      </Teleport>
1✔
19

20
      <!-- Close button for two-column layout (positioned at modal top-right) -->
1✔
21
      <button class="close-button" @click.stop="goBack">
1✔
22
        <v-icon icon="times" />
1✔
23
      </button>
1✔
24

25
      <!-- Two-column layout wrapper for short wide screens -->
1✔
26
      <div class="two-column-wrapper">
1✔
27
        <!-- Photo Area with Ken Burns animation -->
1✔
28
        <div ref="photoAreaRef" class="photo-area" @click="showPhotosModal">
1✔
29
          <!-- Back button on photo (hidden in two-column) -->
1✔
30
          <button class="back-button" @click.stop="goBack">
1✔
31
            <v-icon icon="arrow-left" />
1✔
32
          </button>
1✔
33

34
          <!-- Status overlay images -->
1✔
35
          <b-img
1✔
36
            v-if="message.successful"
1✔
37
            lazy
1✔
38
            src="/freegled.jpg"
1✔
39
            class="status-overlay-image"
1✔
40
            :alt="successfulText"
1✔
41
          />
1✔
42
          <b-img
1✔
43
            v-else-if="message.promised && photoAreaTallEnough"
1✔
44
            lazy
1✔
45
            src="/promised.jpg"
1✔
46
            class="status-overlay-image"
1✔
47
            :class="{ 'status-overlay-image--large': photoAreaIsLarge }"
1✔
48
            alt="Promised"
1✔
49
          />
1✔
50
          <!-- Thumbnail carousel for multiple photos -->
1✔
51
          <div
1✔
52
            v-if="attachmentCount > 1"
1✔
53
            ref="thumbnailsRef"
1✔
54
            class="thumbnail-carousel"
1✔
55
            @touchstart="onThumbnailTouchStart"
1✔
56
            @touchmove="onThumbnailTouchMove"
1✔
57
            @touchend="onThumbnailTouchEnd"
1✔
58
          >
59
            <div
1✔
60
              v-for="(attachment, index) in message.attachments"
1✔
61
              :key="attachment.id || index"
1!
62
              class="thumbnail-item"
1✔
63
              :class="{ active: index === currentPhotoIndex }"
1✔
64
              @click.stop="handleThumbnailClick(index)"
1✔
65
            >
66
              <OurUploadedImage
1✔
67
                v-if="attachment.ouruid"
1✔
68
                :src="attachment.ouruid"
1✔
69
                :modifiers="attachment.externalmods"
1✔
70
                alt="Thumbnail"
1✔
71
                class="thumbnail-image"
1✔
72
                :width="80"
1✔
73
                :height="80"
1✔
74
              />
1✔
75
              <NuxtPicture
1✔
76
                v-else-if="attachment.externaluid"
1✔
77
                format="webp"
1✔
78
                provider="uploadcare"
1✔
79
                :src="attachment.externaluid"
1✔
80
                :modifiers="attachment.externalmods"
1✔
81
                alt="Thumbnail"
1✔
82
                class="thumbnail-image"
1✔
83
                :width="80"
1✔
84
                :height="80"
1✔
85
              />
1✔
86
              <ProxyImage
1✔
87
                v-else-if="attachment.path"
1✔
88
                class-name="thumbnail-image"
1✔
89
                alt="Thumbnail"
1✔
90
                :src="attachment.path"
1✔
91
                :width="80"
1✔
92
                :height="80"
1✔
93
                fit="cover"
1✔
94
              />
1✔
95
            </div>
1✔
96
          </div>
1✔
97

98
          <!-- Actual photo or placeholder -->
1✔
99
          <div
1✔
100
            v-if="gotAttachments"
1✔
101
            class="photo-container"
1✔
102
            :class="{ 'ken-burns': showKenBurns }"
1✔
103
          >
104
            <OurUploadedImage
1✔
105
              v-if="currentAttachment?.ouruid"
1✔
106
              :src="currentAttachment.ouruid"
1✔
107
              :modifiers="currentAttachment.externalmods"
1✔
108
              alt="Item Photo"
1✔
109
              class="photo-image"
1✔
110
              :width="640"
1✔
111
              :height="480"
1✔
112
            />
1✔
113
            <NuxtPicture
1✔
114
              v-else-if="currentAttachment?.externaluid"
1✔
115
              format="webp"
1✔
116
              provider="uploadcare"
1✔
117
              :src="currentAttachment.externaluid"
1✔
118
              :modifiers="currentAttachment.externalmods"
1✔
119
              alt="Item Photo"
1✔
120
              class="photo-image"
1✔
121
              :width="640"
1✔
122
              :height="480"
1✔
123
            />
1✔
124
            <ProxyImage
1✔
125
              v-else-if="currentAttachment?.path"
1✔
126
              class-name="photo-image"
1✔
127
              alt="Item picture"
1✔
128
              :src="currentAttachment.path"
1✔
129
              :width="640"
1✔
130
              :height="480"
1✔
131
              fit="cover"
1✔
132
            />
1✔
133
          </div>
1✔
134

135
          <!-- No photo - show placeholder -->
1✔
136
          <div v-else class="photo-container">
1✔
137
            <MessagePhotoPlaceholder
1✔
138
              :placeholder-class="placeholderClass"
1✔
139
              :icon="categoryIcon"
1✔
140
            />
1✔
141
          </div>
1✔
142

143
          <!-- Poster overlay on photo (shown on shorter screens) -->
1✔
144
          <client-only>
1✔
145
            <div
1✔
146
              v-if="poster"
1✔
147
              class="poster-overlay"
1✔
148
              :class="{ 'poster-overlay--below-carousel': attachmentCount > 1 }"
1✔
149
              @click.stop="showProfileModal = true"
1✔
150
            >
151
              <div class="poster-overlay-avatar-wrapper">
1✔
152
                <ProfileImage
1✔
153
                  :image="poster.profile?.paththumb"
1✔
154
                  :externaluid="poster.profile?.externaluid"
1✔
155
                  :ouruid="poster.profile?.ouruid"
1✔
156
                  :externalmods="poster.profile?.externalmods"
1✔
157
                  :name="poster.displayname"
1✔
158
                  class="poster-overlay-avatar"
1✔
159
                  is-thumbnail
1✔
160
                  size="sm"
1✔
161
                />
1✔
162
                <div v-if="poster.supporter" class="supporter-badge-small">
1✔
163
                  <v-icon icon="trophy" />
1✔
164
                </div>
1✔
165
              </div>
1✔
166
              <div class="poster-overlay-info">
1✔
167
                <span class="poster-overlay-name">{{
1✔
168
                  poster.displayname
1✔
169
                }}</span>
1✔
170
                <div class="poster-overlay-stats">
1✔
171
                  <span v-if="poster.info?.offers" class="poster-overlay-stat">
1✔
172
                    <v-icon icon="gift" />{{ poster.info.offers }}
1✔
173
                  </span>
1✔
174
                  <span
1✔
175
                    v-if="poster.info?.offers && poster.info?.wanteds"
1✔
176
                    class="poster-overlay-separator"
1✔
177
                    >•</span
1✔
178
                  >
1✔
179
                  <span v-if="poster.info?.wanteds" class="poster-overlay-stat">
1✔
180
                    <v-icon icon="search" />{{ poster.info.wanteds }}
1✔
181
                  </span>
1✔
182
                </div>
1✔
183
              </div>
1✔
184
              <v-icon icon="chevron-right" class="poster-overlay-chevron" />
1✔
185
            </div>
1✔
186
          </client-only>
1✔
187

188
          <!-- Title overlay at bottom of photo - matches summary layout -->
1✔
189
          <div class="title-overlay">
1✔
190
            <div class="info-row">
1✔
191
              <MessageTag :id="id" :inline="true" class="title-tag ps-1 pe-1" />
1✔
192
              <div class="info-icons">
1✔
193
                <client-only>
1✔
194
                  <span
1✔
195
                    v-if="distanceText"
1✔
196
                    v-b-tooltip.hover.click.blur="{
1✔
197
                      title: 'Show on map',
198
                      customClass: 'mobile-tooltip',
199
                    }"
1✔
200
                    class="location"
1✔
201
                    @click.stop="showMapModal = true"
1✔
202
                  >
203
                    <v-icon icon="map-marker-alt" />{{ distanceText }}
1✔
204
                  </span>
1✔
205
                  <span
1✔
206
                    v-b-tooltip.hover.click.blur="{
1✔
207
                      title: fullTimeAgo,
208
                      customClass: 'mobile-tooltip',
209
                    }"
1✔
210
                    class="time"
1✔
211
                    @click.stop
1✔
212
                  >
213
                    <v-icon icon="clock" />{{ timeAgo }}
1✔
214
                  </span>
1✔
215
                  <span
1✔
216
                    v-b-tooltip.hover.click.blur="{
1✔
217
                      title: replyTooltip,
218
                      customClass: 'mobile-tooltip',
219
                    }"
1✔
220
                    class="replies"
1✔
221
                    @click.stop
1✔
222
                  >
223
                    <v-icon icon="reply" />{{ replyCount }}
1✔
224
                  </span>
1✔
225
                  <span
1✔
226
                    v-if="message.deliverypossible && isOffer"
1✔
227
                    v-b-tooltip.hover.click.blur="{
1✔
228
                      title: `Delivery may be possible - you can ask, but don't assume it will be`,
229
                      customClass: 'mobile-tooltip',
230
                    }"
1✔
231
                    class="delivery"
1✔
232
                    @click.stop
1✔
233
                  >
234
                    <v-icon icon="truck" />?
1✔
235
                  </span>
1✔
236
                  <span
1✔
237
                    v-if="message.deadline"
1✔
238
                    v-b-tooltip.hover.click.blur="{
1✔
239
                      title: deadlineTooltip,
240
                      customClass: 'mobile-tooltip',
241
                    }"
1✔
242
                    class="deadline"
1✔
243
                    @click.stop
1✔
244
                  >
245
                    <v-icon icon="hourglass-end" />Ends {{ formattedDeadline }}
1✔
246
                  </span>
1✔
247
                </client-only>
1✔
248
              </div>
1✔
249
            </div>
1✔
250
            <div class="title-row">
1✔
251
              <span class="title-subject"
1✔
252
                >{{ subjectItemName }}
1✔
253
                <b-badge
1✔
254
                  v-if="message.availablenow > 1"
1✔
255
                  variant="info"
1✔
256
                  class="ms-1"
1✔
257
                  style="font-size: 0.55em; vertical-align: middle"
1✔
258
                >
259
                  {{ message.availablenow }} available
1✔
260
                </b-badge></span
1✔
261
              >
1✔
262
            </div>
1✔
263
            <div class="location-row">
1✔
264
              <div v-if="subjectLocation" class="title-location">
1✔
265
                {{ subjectLocation }}
7,922✔
266
              </div>
1✔
267
              <div class="photo-actions">
1✔
268
                <button
1✔
269
                  class="photo-action-btn"
1✔
270
                  @click.stop="showShareModal = true"
1✔
271
                >
272
                  <v-icon icon="share-alt" />
1✔
273
                </button>
1✔
274
              </div>
1✔
275
            </div>
1✔
276
          </div>
1✔
277
        </div>
1✔
278

279
        <!-- Right column: Info + Reply (for two-column layout) -->
1✔
280
        <div class="right-column">
1✔
281
          <!-- Info Section -->
1✔
282
          <div class="info-section">
1✔
283
            <!-- Description -->
1✔
284
            <div class="description-section">
1✔
285
              <div class="section-header">
1✔
286
                <span class="section-header-text">DESCRIPTION</span>
1✔
287
                <div class="section-header-actions">
1✔
288
                  <button
1✔
289
                    class="action-button"
1✔
290
                    title="Share this post"
1✔
291
                    @click.stop="showShare"
1✔
292
                  >
293
                    <v-icon icon="share-alt" />
1✔
294
                    <span class="action-button-text">Share</span>
1✔
295
                  </button>
1✔
296
                  <client-only>
1✔
297
                    <button
1✔
298
                      v-if="loggedIn && message.groups?.length"
1✔
299
                      class="action-button action-button--report"
1✔
300
                      title="Report this post"
1✔
301
                      @click.stop="showReport"
1✔
302
                    >
303
                      <v-icon icon="flag" />
1✔
304
                      <span class="action-button-text">Report</span>
1✔
305
                    </button>
1✔
306
                  </client-only>
1✔
307
                  <NuxtLink
1✔
308
                    :to="'/message/' + id"
1✔
309
                    class="section-id-link"
1✔
310
                    @click.stop
1✔
311
                  >
1✔
312
                    #{{ id }}
1✔
313
                  </NuxtLink>
1✔
314
                </div>
1✔
315
              </div>
1✔
316
              <div
1✔
317
                class="description-content"
1✔
318
                :class="{
1✔
319
                  'description-content--promised':
320
                    message.promised && !message.successful,
321
                }"
322
              >
323
                <MessageTextBody :id="id" />
1✔
324
              </div>
1✔
325
            </div>
1✔
326

327
            <!-- Posted by divider and section (shown on taller screens, after description) -->
1✔
328
            <client-only>
1✔
329
              <div v-if="poster" class="section-header section-header--poster">
1✔
330
                <span>
1✔
331
                  <span class="section-header-text">POSTED BY</span>
1✔
332
                  <span class="section-header-name">{{
1✔
333
                    poster.displayname
1✔
334
                  }}</span>
1✔
335
                </span>
1✔
336
                <span
1✔
337
                  class="section-id-link"
1✔
338
                  @click.stop="showProfileModal = true"
1✔
339
                >
1✔
340
                  #{{ poster.id }}
1✔
341
                </span>
1✔
342
              </div>
1✔
343
              <div
1✔
344
                v-if="poster"
1✔
345
                class="poster-section-wrapper"
1✔
346
                @click.stop="showProfileModal = true"
1✔
347
              >
348
                <div class="poster-avatar-wrapper">
1✔
349
                  <ProfileImage
1✔
350
                    :image="poster.profile?.paththumb"
1✔
351
                    :externaluid="poster.profile?.externaluid"
1✔
352
                    :ouruid="poster.profile?.ouruid"
1✔
353
                    :externalmods="poster.profile?.externalmods"
1✔
354
                    :name="poster.displayname"
1✔
355
                    class="poster-avatar"
1✔
356
                    is-thumbnail
1✔
357
                    size="lg"
1✔
358
                  />
1✔
359
                  <div v-if="poster.supporter" class="supporter-badge">
1✔
360
                    <v-icon icon="trophy" />
1✔
361
                  </div>
1✔
362
                </div>
1✔
363
                <div class="poster-details">
1✔
364
                  <span class="poster-name">{{ poster.displayname }}</span>
1✔
365
                  <div class="poster-stats">
1✔
366
                    <span v-if="poster.info?.offers" class="poster-stat">
1✔
367
                      <v-icon icon="gift" />{{ poster.info.offers
1✔
368
                      }}<span class="poster-stat-label">OFFERs</span>
1✔
369
                    </span>
1✔
370
                    <span
1✔
371
                      v-if="poster.info?.offers && poster.info?.wanteds"
1✔
372
                      class="poster-stat-separator"
1✔
373
                      >•</span
1✔
374
                    >
1✔
375
                    <span v-if="poster.info?.wanteds" class="poster-stat">
1✔
376
                      <v-icon icon="search" />{{ poster.info.wanteds
1✔
377
                      }}<span class="poster-stat-label">WANTEDs</span>
1✔
378
                    </span>
1✔
379
                  </div>
1✔
380
                  <div v-if="posterAboutMe" class="poster-aboutme">
1✔
381
                    {{ posterAboutMe }}
7,943✔
382
                  </div>
1✔
383
                </div>
1✔
384
                <UserRatings
1✔
385
                  v-if="poster.id"
1✔
386
                  :id="poster.id"
1✔
387
                  size="md"
1✔
388
                  :disabled="fromme"
1✔
389
                  class="poster-ratings"
1✔
390
                  @click.stop.prevent
1✔
391
                />
1✔
392
                <v-icon icon="chevron-right" class="poster-chevron" />
1✔
393
              </div>
1✔
394
            </client-only>
1✔
395
          </div>
1✔
396

397
          <!-- Inline reply section for two-column layout -->
1✔
398
          <div v-if="isTwoColumnLayout" class="inline-reply-section">
1✔
399
            <div v-if="!replyExpanded">
1✔
400
              <!-- Promised notice -->
1✔
401
              <div
1✔
402
                v-if="
1✔
403
                  message.promised &&
404
                  !message.successful &&
405
                  replyable &&
406
                  !fromme
407
                "
408
                class="promised-notice mb-2"
1✔
409
              >
410
                <v-icon icon="handshake" />
1✔
411
                {{
1✔
412
                  message.promisedtome ? 'Promised to you' : 'Already promised'
1✔
413
                }}
414
              </div>
1✔
415
              <div
1✔
416
                v-if="replyable && !replied && !message.successful"
1✔
417
                class="footer-buttons"
1✔
418
              >
419
                <b-button
1✔
420
                  v-if="inModal || fullscreenOverlay"
1✔
421
                  variant="secondary"
1✔
422
                  size="lg"
1✔
423
                  class="cancel-button"
1✔
424
                  @click="goBack"
1✔
425
                >
1✔
426
                  Cancel
427
                </b-button>
1✔
428
                <b-button
1✔
429
                  v-if="!fromme"
1✔
430
                  variant="primary"
1✔
431
                  size="lg"
1✔
432
                  class="reply-button"
1✔
433
                  @click="expandReply"
1✔
434
                >
1✔
435
                  Reply
436
                </b-button>
1✔
437
              </div>
1✔
438
              <b-alert
1✔
439
                v-else-if="replied"
1✔
440
                variant="info"
1✔
441
                :model-value="true"
1✔
442
                class="mb-0"
1✔
443
              >
1✔
444
                Message sent! Check your
445
                <nuxt-link to="/chats">Chats</nuxt-link>.
1✔
446
              </b-alert>
1✔
447
            </div>
1✔
448

449
            <!-- Expanded reply section -->
1✔
450
            <div v-else class="reply-expanded-section">
1✔
451
              <NoticeMessage
1✔
452
                v-if="message.promised && !message.promisedtome"
1✔
453
                variant="warning"
1✔
454
                class="mb-2"
1✔
455
              >
1✔
456
                Already promised - you might not get it.
457
              </NoticeMessage>
1✔
458
              <client-only>
1✔
459
                <MessageReplySection
1✔
460
                  :id="id"
1✔
461
                  @close="replyExpanded = false"
1✔
462
                  @sent="sent"
1✔
463
                />
1✔
464
              </client-only>
1✔
465
            </div>
1✔
466
          </div>
1✔
467
        </div>
1✔
468
      </div>
1✔
469
    </div>
1✔
470

471
    <!-- Fixed footer with Reply button - for single column layout only -->
1✔
472
    <div
1✔
473
      v-if="!isTwoColumnLayout"
1✔
474
      class="app-footer"
1✔
475
      :class="{ expanded: replyExpanded, stickyAdRendered }"
1✔
476
    >
477
      <div v-if="!replyExpanded" class="w-100">
1✔
478
        <!-- Promised notice -->
1✔
479
        <div
1✔
480
          v-if="message.promised && !message.successful && replyable && !fromme"
1✔
481
          class="promised-notice mb-2"
1✔
482
        >
483
          <v-icon icon="handshake" />
1✔
484
          {{ message.promisedtome ? 'Promised to you' : 'Already promised' }}
1✔
485
        </div>
1✔
486
        <div
1✔
487
          v-if="replyable && !replied && !message.successful"
1✔
488
          class="footer-buttons"
1✔
489
        >
490
          <b-button
1✔
491
            v-if="inModal || fullscreenOverlay"
1✔
492
            variant="secondary"
1✔
493
            size="lg"
1✔
494
            class="cancel-button"
1✔
495
            @click="goBack"
1✔
496
          >
1✔
497
            Cancel
498
          </b-button>
1✔
499
          <b-button
1✔
500
            v-if="!fromme"
1✔
501
            variant="primary"
1✔
502
            size="lg"
1✔
503
            class="reply-button"
1✔
504
            @click="expandReply"
1✔
505
          >
1✔
506
            Reply
507
          </b-button>
1✔
508
        </div>
1✔
509
        <b-alert
1✔
510
          v-else-if="replied"
1✔
511
          variant="info"
1✔
512
          :model-value="true"
1✔
513
          class="mb-0"
1✔
514
        >
1✔
515
          Message sent! Check your <nuxt-link to="/chats">Chats</nuxt-link>.
1✔
516
        </b-alert>
2✔
517
      </div>
1✔
518

519
      <!-- Expanded reply section -->
1✔
520
      <div v-else class="reply-expanded-section">
1✔
521
        <NoticeMessage
1✔
522
          v-if="message.promised && !message.promisedtome"
1✔
523
          variant="warning"
1✔
524
          class="mb-2"
1✔
525
        >
1✔
526
          Already promised - you might not get it.
527
        </NoticeMessage>
1✔
528
        <client-only>
1✔
529
          <MessageReplySection
1✔
530
            :id="id"
1✔
531
            @close="replyExpanded = false"
1✔
532
            @sent="sent"
1✔
533
          />
1✔
534
        </client-only>
1✔
535
      </div>
1✔
536
    </div>
1✔
537

538
    <!-- Map Modal - Full Screen -->
1✔
539
    <Teleport v-if="showMapModal" to="body">
1✔
540
      <div class="fullscreen-map-viewer">
1✔
541
        <button class="map-back-button" @click="showMapModal = false">
1✔
542
          <v-icon icon="arrow-left" />
1✔
543
        </button>
1✔
544
        <client-only>
1✔
545
          <MessageMap
1✔
546
            v-if="validPosition"
1✔
547
            :home="home"
1✔
548
            :position="{ lat: message.lat, lng: message.lng }"
1✔
549
            class="fullscreen-map"
1✔
550
          />
1✔
551
        </client-only>
1✔
552
        <div class="map-hint">
1✔
553
          <v-icon icon="info-circle" /> Approximate location shown
1✔
554
        </div>
1✔
555
      </div>
1✔
556
    </Teleport>
1✔
557

558
    <!-- Photos Modal -->
1✔
559
    <MessagePhotosModal
1✔
560
      v-if="showMessagePhotosModal && attachmentCount"
1✔
561
      :id="message.id"
1✔
562
      :initial-index="currentPhotoIndex"
1✔
563
      @hidden="showMessagePhotosModal = false"
1✔
564
    />
1✔
565

566
    <!-- Share Modal -->
1✔
567
    <MessageShareModal
1✔
568
      v-if="showShareModal && message.url"
1✔
569
      :id="message.id"
1✔
570
      @hidden="showShareModal = false"
1✔
571
    />
1✔
572

573
    <!-- Profile Modal -->
1✔
574
    <ProfileModal
1✔
575
      v-if="showProfileModal && poster?.id"
1!
576
      :id="poster.id"
1✔
577
      @hidden="showProfileModal = false"
1✔
578
    />
1✔
579

580
    <!-- Report Modal -->
1✔
581
    <MessageReportModal
1✔
582
      v-if="showReportModal"
1✔
583
      :id="id"
1✔
584
      @hidden="showReportModal = false"
1✔
585
    />
1✔
586
  </div>
1✔
587
</template>
588

589
<script setup>
590
import {
591
  ref,
592
  computed,
593
  defineAsyncComponent,
594
  onMounted,
595
  onUnmounted,
596
} from 'vue'
1✔
597
import { useMiscStore } from '~/stores/misc'
1✔
598
import { useMobileStore } from '~/stores/mobile'
1✔
599
import { useMe } from '~/composables/useMe'
1✔
600
import { useMessageDisplay } from '~/composables/useMessageDisplay'
1✔
601
import { action } from '~/composables/useClientLog'
1✔
602
import MessageTextBody from '~/components/MessageTextBody'
1✔
603
import MessageTag from '~/components/MessageTag'
1✔
604
import NoticeMessage from '~/components/NoticeMessage'
1✔
605
import MessageReplySection from '~/components/MessageReplySection'
1✔
606
import ProfileImage from '~/components/ProfileImage'
1✔
607
import UserRatings from '~/components/UserRatings'
1✔
608
import { useModalHistory } from '~/composables/useModalHistory'
1✔
609

610
const MessageMap = defineAsyncComponent(() => import('~/components/MessageMap'))
1✔
611
const MessagePhotosModal = defineAsyncComponent(() =>
1✔
UNCOV
612
  import('~/components/MessagePhotosModal')
×
613
)
1✔
614
const MessageShareModal = defineAsyncComponent(() =>
1✔
UNCOV
615
  import('~/components/MessageShareModal')
×
616
)
1✔
617
const ProfileModal = defineAsyncComponent(() =>
1✔
UNCOV
618
  import('~/components/ProfileModal')
×
619
)
1✔
620
const MessageReportModal = defineAsyncComponent(() =>
1✔
UNCOV
621
  import('~/components/MessageReportModal')
×
622
)
1✔
623

624
const props = defineProps({
1✔
625
  id: {
626
    type: Number,
627
    required: true,
628
  },
629
  replyable: {
630
    type: Boolean,
631
    default: true,
632
  },
633
  hideClose: {
634
    type: Boolean,
635
    default: false,
636
  },
637
  actions: {
638
    type: Boolean,
639
    default: true,
640
  },
641
  inModal: {
642
    type: Boolean,
643
    default: false,
644
  },
645
  fullscreenOverlay: {
646
    type: Boolean,
647
    default: false,
648
  },
649
})
650

651
const emit = defineEmits(['zoom', 'close'])
1✔
652

653
const miscStore = useMiscStore()
1✔
654
const mobileStore = useMobileStore()
1✔
655
const { me, loggedIn } = useMe()
1✔
656

657
// Use shared composable for common message display logic
1✔
658
const {
1✔
659
  message,
1✔
660
  subjectItemName,
1✔
661
  subjectLocation,
1✔
662
  fromme,
1✔
663
  gotAttachments,
1✔
664
  attachmentCount,
1✔
665
  timeAgo,
1✔
666
  fullTimeAgo,
1✔
667
  distanceText,
1✔
668
  replyCount,
1✔
669
  replyTooltip,
1✔
670
  isOffer,
1✔
671
  formattedDeadline,
1✔
672
  deadlineTooltip,
1✔
673
  successfulText,
1✔
674
  placeholderClass,
1✔
675
  categoryIcon,
1✔
676
  poster,
1✔
677
} = useMessageDisplay(props.id)
1✔
678

679
const stickyAdRendered = computed(() => miscStore.stickyAdRendered)
1✔
680

681
// State
1✔
682
const replied = ref(false)
1✔
683
const replyExpanded = ref(false)
1✔
684
const mountTime = ref(null)
1✔
685
const showMapModal = ref(false)
1✔
686
const showShareModal = ref(false)
1✔
687
const showProfileModal = ref(false)
1✔
688
const showMessagePhotosModal = ref(false)
1✔
689
const showReportModal = ref(false)
1✔
690
const currentPhotoIndex = ref(0)
1✔
691
const containerRef = ref(null)
1✔
692
const photoAreaRef = ref(null)
1✔
693
const photoAreaHeight = ref(0)
1✔
694
const thumbnailsRef = ref(null)
1✔
695
const thumbnailTouchStartX = ref(0)
1✔
696
const thumbnailScrollStart = ref(0)
1✔
697
let thumbnailScrollInterval = null
1✔
698
let photoAreaObserver = null
1✔
699

700
// Computed (additional to composable)
1✔
701
const currentAttachment = computed(() => {
1✔
702
  return message.value?.attachments?.[currentPhotoIndex.value]
7,180✔
703
})
7,180✔
704

705
const validPosition = computed(() => {
1✔
706
  return message.value?.lat || message.value?.lng
2✔
707
})
2✔
708

709
const home = computed(() => {
1✔
710
  if (me.value?.lat || me.value?.lng) {
2✔
711
    return { lat: me.value.lat, lng: me.value.lng }
1✔
712
  }
1✔
713
  return null
1✔
714
})
1✔
715

716
const prefersReducedMotion = computed(() => {
1✔
717
  if (typeof window === 'undefined') return false
112!
718
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches
112✔
719
})
112✔
720

721
// Defer ken-burns animation to after mount to avoid SSR hydration mismatch
1✔
722
const isMounted = ref(false)
1✔
723
const showKenBurns = computed(() => {
1✔
724
  return isMounted.value && !prefersReducedMotion.value
220✔
725
})
220✔
726

727
// Check if navbar-mobile teleport target exists (only after mount to avoid hydration mismatch)
1✔
728
const navbarMobileExists = computed(() => {
1✔
729
  if (!isMounted.value) return false
223✔
730
  return !!document.getElementById('navbar-mobile')
112✔
731
})
112✔
732

733
// Detect two-column layout (width >= xl breakpoint AND height <= 700px)
1✔
734
// Only evaluate after mount to avoid SSR hydration mismatch
1✔
735
const windowHeight = ref(0)
1✔
736
const isTwoColumnLayout = computed(() => {
1✔
737
  if (!isMounted.value) return false
266✔
738
  // Only use 2-column layout in modal or overlay - standalone pages use single column
155✔
739
  if (!props.inModal && !props.fullscreenOverlay) return false
266✔
740
  // Use miscStore breakpoint for width (xl = 1200px+)
56✔
741
  const isWideEnough = ['xl', 'xxl'].includes(miscStore.breakpoint)
56✔
742
  const isShortEnough = windowHeight.value <= 700
56✔
743
  return isWideEnough && isShortEnough
266✔
744
})
266✔
745

746
const photoAreaTallEnough = computed(() => photoAreaHeight.value >= 150)
1✔
747
const photoAreaIsLarge = computed(() => photoAreaHeight.value >= 300)
1✔
748

749
const posterAboutMe = computed(() => {
1✔
750
  const text = poster.value?.aboutme?.text
6,675✔
751
  if (!text) return null
6,675✔
752
  return text
6,498✔
753
})
6,498✔
754

755
// Methods
1✔
756
function goBack() {
5✔
757
  emit('close')
5✔
758
}
5✔
759

760
function showPhotosModal() {
5✔
761
  if (gotAttachments.value) {
5✔
762
    showMessagePhotosModal.value = true
3✔
763
    emit('zoom')
3✔
764
  }
3✔
765
}
5✔
766

767
function showShare() {
1✔
768
  showShareModal.value = true
1✔
769
}
1✔
770

771
function showReport() {
1✔
772
  showReportModal.value = true
1✔
773
}
1✔
774

775
function selectPhoto(index) {
3✔
776
  currentPhotoIndex.value = index
3✔
777
}
3✔
778

779
function handleThumbnailClick(index) {
3✔
780
  if (index === currentPhotoIndex.value) {
3✔
781
    // Clicking the already selected thumbnail opens the photo viewer
1✔
782
    showPhotosModal()
1✔
783
  } else {
3✔
784
    selectPhoto(index)
2✔
785
  }
2✔
786
}
3✔
787

788
function onThumbnailTouchStart(e) {
×
789
  thumbnailTouchStartX.value = e.touches[0].clientX
×
UNCOV
790
  thumbnailScrollStart.value = thumbnailsRef.value?.scrollLeft || 0
×
UNCOV
791
}
×
792

793
function onThumbnailTouchMove(e) {
×
794
  if (!thumbnailsRef.value) return
×
795
  const deltaX = thumbnailTouchStartX.value - e.touches[0].clientX
×
796
  thumbnailsRef.value.scrollLeft = thumbnailScrollStart.value + deltaX
×
UNCOV
797
}
×
798

799
function onThumbnailTouchEnd() {
×
UNCOV
800
  // Swipe complete, scroll position is already set
×
UNCOV
801
}
×
802

803
function startThumbnailAutoScroll() {
112✔
804
  if (!thumbnailsRef.value || attachmentCount.value <= 1) return
112✔
805

806
  // Wait a moment then scroll right slowly, then back
6✔
807
  setTimeout(() => {
6✔
808
    if (!thumbnailsRef.value) return
5✔
809
    const maxScroll =
2✔
810
      thumbnailsRef.value.scrollWidth - thumbnailsRef.value.clientWidth
2✔
811
    if (maxScroll <= 0) return
5!
812

UNCOV
813
    // Animate scroll manually for smoother control
×
814
    const duration = 2000 // 2 seconds to scroll
×
UNCOV
815
    const startTime = performance.now()
×
UNCOV
816
    const startPos = 0
×
817

818
    function animateScroll(currentTime) {
×
819
      if (!thumbnailsRef.value) return
×
820
      const elapsed = currentTime - startTime
×
UNCOV
821
      const progress = Math.min(elapsed / duration, 1)
×
UNCOV
822
      // Ease in-out
×
UNCOV
823
      const eased =
×
UNCOV
824
        progress < 0.5
×
UNCOV
825
          ? 2 * progress * progress
×
UNCOV
826
          : 1 - Math.pow(-2 * progress + 2, 2) / 2
×
827
      thumbnailsRef.value.scrollLeft = startPos + maxScroll * eased
×
828

UNCOV
829
      if (progress < 1) {
×
UNCOV
830
        requestAnimationFrame(animateScroll)
×
UNCOV
831
      } else {
×
UNCOV
832
        // Pause then scroll back
×
UNCOV
833
        setTimeout(() => {
×
834
          if (!thumbnailsRef.value) return
×
835
          const backStartTime = performance.now()
×
836
          function animateBack(currentTime) {
×
837
            if (!thumbnailsRef.value) return
×
838
            const elapsed = currentTime - backStartTime
×
UNCOV
839
            const progress = Math.min(elapsed / duration, 1)
×
UNCOV
840
            const eased =
×
UNCOV
841
              progress < 0.5
×
UNCOV
842
                ? 2 * progress * progress
×
UNCOV
843
                : 1 - Math.pow(-2 * progress + 2, 2) / 2
×
844
            thumbnailsRef.value.scrollLeft = maxScroll - maxScroll * eased
×
UNCOV
845
            if (progress < 1) {
×
UNCOV
846
              requestAnimationFrame(animateBack)
×
UNCOV
847
            }
×
UNCOV
848
          }
×
849
          requestAnimationFrame(animateBack)
×
UNCOV
850
        }, 1000)
×
UNCOV
851
      }
×
UNCOV
852
    }
×
853
    requestAnimationFrame(animateScroll)
×
854
  }, 1000)
6✔
855
}
6✔
856

UNCOV
857
function stopThumbnailAutoScroll() {
×
UNCOV
858
  if (thumbnailScrollInterval) {
×
UNCOV
859
    clearInterval(thumbnailScrollInterval)
×
UNCOV
860
    thumbnailScrollInterval = null
×
UNCOV
861
  }
×
UNCOV
862
}
×
863

864
function expandReply() {
5✔
865
  console.log(
5✔
866
    'DEBUG expandReply called, replyable:',
5✔
867
    props.replyable,
5✔
868
    'replied:',
5✔
869
    replied.value,
5✔
870
    'fromme:',
5✔
871
    fromme.value
5✔
872
  )
5✔
873
  replyExpanded.value = true
5✔
874
}
5✔
875

876
function sent() {
2✔
877
  replyExpanded.value = false
2✔
878
  replied.value = true
2✔
879
  // Close after a brief delay so user sees confirmation
2✔
880
  setTimeout(() => {
2✔
881
    emit('close')
2✔
882
  }, 1500)
2✔
883
}
2✔
884

885
// Handle browser back button/swipe
1✔
886
useModalHistory(`message-${props.id}`, () => emit('close'), true)
1✔
887

888
function updateWindowHeight() {
112✔
889
  windowHeight.value = window.innerHeight
112✔
890
}
112✔
891

892
onMounted(() => {
1✔
893
  mountTime.value = Date.now()
112✔
894

895
  // Log mount for debugging mobile navigation issues.
112✔
896
  action('message_expanded_mount', {
112✔
897
    message_id: props.id,
112✔
898
    fullscreen_overlay: props.fullscreenOverlay,
112✔
899
    in_modal: props.inModal,
112✔
900
    breakpoint: miscStore.breakpoint,
112✔
901
  })
112✔
902

903
  // Prevent orientation changes while fullscreen overlay is open - keyboard opening
112✔
904
  // changes viewport dimensions which would incorrectly trigger landscape mode and
112✔
905
  // cause ScrollGrid to unmount/remount components, losing the modal state.
112✔
906
  if (props.fullscreenOverlay) {
112✔
907
    miscStore.setFullscreenModalOpen(true)
5✔
908
  }
5✔
909

910
  // Enable ken-burns animation now that hydration is complete
112✔
911
  isMounted.value = true
112✔
912

913
  // Track window height for two-column layout detection (width via miscStore.breakpoint)
112✔
914
  updateWindowHeight()
112✔
915
  window.addEventListener('resize', updateWindowHeight)
112✔
916

917
  // Track photo area height for conditional overlay sizing
112✔
918
  if (photoAreaRef.value) {
112✔
919
    photoAreaHeight.value = photoAreaRef.value.clientHeight
111✔
920
    photoAreaObserver = new ResizeObserver((entries) => {
111✔
UNCOV
921
      photoAreaHeight.value = entries[0].contentRect.height
×
922
    })
111✔
923
    photoAreaObserver.observe(photoAreaRef.value)
111✔
924
  }
111✔
925

926
  // Start auto-scroll hint for thumbnail carousel
112✔
927
  startThumbnailAutoScroll()
112✔
928
})
112✔
929

930
onUnmounted(() => {
1✔
UNCOV
931
  const timeOpenMs = mountTime.value ? Date.now() - mountTime.value : null
×
932

UNCOV
933
  action('message_expanded_unmount', {
×
UNCOV
934
    message_id: props.id,
×
UNCOV
935
    fullscreen_overlay: props.fullscreenOverlay,
×
UNCOV
936
    time_open_ms: timeOpenMs,
×
UNCOV
937
  })
×
938

UNCOV
939
  // Clear the fullscreen modal flag so orientation detection resumes.
×
UNCOV
940
  // Re-check orientation since it may have changed while blocked.
×
UNCOV
941
  if (props.fullscreenOverlay) {
×
UNCOV
942
    miscStore.setFullscreenModalOpen(false)
×
UNCOV
943
    // Use same detection method as OrientationFettler: Capacitor for app, matchMedia for web.
×
944
    if (mobileStore.isApp) {
×
UNCOV
945
      // In app, use Capacitor ScreenOrientation plugin.
×
UNCOV
946
      import('@capacitor/screen-orientation')
×
UNCOV
947
        .then(({ ScreenOrientation }) => {
×
948
          ScreenOrientation.orientation().then((orientation) => {
×
949
            const isLandscape =
×
UNCOV
950
              orientation.type === 'landscape-primary' ||
×
UNCOV
951
              orientation.type === 'landscape-secondary'
×
952
            miscStore.setLandscape(isLandscape)
×
UNCOV
953
          })
×
UNCOV
954
        })
×
UNCOV
955
        .catch(() => {
×
UNCOV
956
          // Fallback to matchMedia if plugin unavailable.
×
957
          if (typeof window !== 'undefined') {
×
UNCOV
958
            miscStore.setLandscape(
×
UNCOV
959
              window.matchMedia('(orientation: landscape)').matches
×
UNCOV
960
            )
×
UNCOV
961
          }
×
UNCOV
962
        })
×
UNCOV
963
    } else if (typeof window !== 'undefined') {
×
UNCOV
964
      miscStore.setLandscape(
×
UNCOV
965
        window.matchMedia('(orientation: landscape)').matches
×
UNCOV
966
      )
×
UNCOV
967
    }
×
UNCOV
968
  }
×
969

UNCOV
970
  if (photoAreaObserver) {
×
UNCOV
971
    photoAreaObserver.disconnect()
×
UNCOV
972
    photoAreaObserver = null
×
UNCOV
973
  }
×
974

UNCOV
975
  stopThumbnailAutoScroll()
×
UNCOV
976
  window.removeEventListener('resize', updateWindowHeight)
×
UNCOV
977
})
×
978
</script>
979

980
<style scoped lang="scss">
981
@import 'bootstrap/scss/functions';
982
@import 'bootstrap/scss/variables';
983
@import 'bootstrap/scss/mixins/_breakpoints';
984
@import 'assets/css/sticky-banner.scss';
985
@import 'assets/css/_color-vars.scss';
986
@import 'assets/css/navbar.scss';
987

988
/*
989
 * LAYOUT PLAN
990
 * ===========
991
 *
992
 * Two wrapper modes controlled by props:
993
 *   - fullscreenOverlay: position: fixed (mobile expand from browse page)
994
 *   - inModal: position: relative (b-modal handles positioning)
995
 *
996
 * Flexbox layout inside both modes:
997
 *   - photo-area: flex: 1 1 0 (grows to fill available space)
998
 *   - info-section: flex: 0 0 auto (sizes to content, max 50% height, scrolls)
999
 *   - app-footer: flex-shrink: 0 (fixed height at bottom)
1000
 *
1001
 * DIAGRAM A: Single Column Mode
1002
 * =============================
1003
 *
1004
 *    ┌──────────────────────────┐
1005
 *    │ [←] Photo Area           │  ← Back button (fullscreen) or [×] (modal)
1006
 *    │     (flex: 1 1 0)        │
1007
 *    │                          │
1008
 *    │  ┌─────────────────────┐ │
1009
 *    │  │ Title overlay       │ │  ← At bottom of photo
1010
 *    │  │ OFFER · 2mi · 3h    │ │
1011
 *    │  └─────────────────────┘ │
1012
 *    ├──────────────────────────┤
1013
 *    │ DESCRIPTION              │  ← Scrollable, max 50% height
1014
 *    │ Description text...      │    (flex: 0 0 auto)
1015
 *    │                          │
1016
 *    │ POSTED BY                │  ← Hidden on short screens (≤700px height)
1017
 *    │ [Avatar] Name            │    (poster-overlay shown on photo instead)
1018
 *    ├──────────────────────────┤
1019
 *    │ [Cancel]     [Reply]     │  ← Fixed footer (flex-shrink: 0)
1020
 *    └──────────────────────────┘
1021
 *
1022
 * DIAGRAM B: Two-Column Mode
1023
 * ==========================
1024
 *
1025
 *    ┌─────────────────────────────────────┐
1026
 *    │                                 [×] │  ← Close button, no back button
1027
 *    ├──────────────────┬──────────────────┤
1028
 *    │                  │ Description      │  ← Right column: grid 1fr + auto
1029
 *    │   Photo          │ (scrolls if      │
1030
 *    │   (50% width,    │  needed)         │
1031
 *    │    full height)  │                  │
1032
 *    │                  │ Posted by...     │  ← Always in section (not overlay)
1033
 *    │                  ├──────────────────┤
1034
 *    │                  │ [Cancel] [Reply] │  ← inline-reply-section (grid auto row)
1035
 *    └──────────────────┴──────────────────┘
1036
 *
1037
 *    - app-footer hidden (uses inline-reply-section instead)
1038
 *    - CSS: grid-template-columns: 1fr 1fr at @media (min-width: 1200px) and (max-height: 700px)
1039
 *
1040
 * RESPONSIVE LAYOUT TABLE
1041
 * =======================
1042
 *
1043
 * Breakpoints: xs(<576) sm(576-767) md(768-991) lg(992-1199) xl(1200+)
1044
 * Height: Short(≤700px) Tall(>700px)
1045
 *
1046
 * ┌─────────┬────────┬─────────┬─────────┬────────┬────────┬──────────┬────────────┐
1047
 * │ Width   │ Height │ Columns │ Back [←]│Close[×]│ Poster │ Aboutme  │ Ratings    │
1048
 * │         │        │ (→ dia.)│(overlay)│(modal) │ where  │ visible  │ visible    │
1049
 * ├─────────┼────────┼─────────┼─────────┼────────┼────────┼──────────┼────────────┤
1050
 * │ xs      │ Short  │ 1 (A)   │ ✓       │ (modal)│ overlay│ ✗        │ ✗          │
1051
 * │ xs      │ Tall   │ 1 (A)   │ ✓       │ (modal)│ section│ ✗        │ ✗          │
1052
 * ├─────────┼────────┼─────────┼─────────┼────────┼────────┼──────────┼────────────┤
1053
 * │ sm      │ Short  │ 1 (A)   │ ✓       │ (modal)│ overlay│ ✗        │ ✗          │
1054
 * │ sm      │ Tall   │ 1 (A)   │ ✓       │ (modal)│ section│ ✗        │ ✗          │
1055
 * ├─────────┼────────┼─────────┼─────────┼────────┼────────┼──────────┼────────────┤
1056
 * │ md      │ Short  │ 1 (A)   │ ✓       │ (modal)│ overlay│ ✗*       │ ✗*         │
1057
 * │ md      │ Tall   │ 1 (A)   │ ✓       │ (modal)│ section│ ✓        │ ✓          │
1058
 * ├─────────┼────────┼─────────┼─────────┼────────┼────────┼──────────┼────────────┤
1059
 * │ lg      │ Short  │ 1 (A)   │ ✓       │ (modal)│ overlay│ ✗*       │ ✗*         │
1060
 * │ lg      │ Tall   │ 1 (A)   │ ✓       │ (modal)│ section│ ✓        │ ✓          │
1061
 * ├─────────┼────────┼─────────┼─────────┼────────┼────────┼──────────┼────────────┤
1062
 * │ xl+     │ Short  │ 2 (B)   │ ✗       │ ✓      │ section│ ✓        │ ✓          │
1063
 * │ xl+     │ Tall   │ 1 (A)   │ ✓       │ (modal)│ section│ ✓        │ ✓          │
1064
 * └─────────┴────────┴─────────┴─────────┴────────┴────────┴──────────┴────────────┘
1065
 *
1066
 * Legend:
1067
 * - Columns: 1 (A) → see Diagram A above; 2 (B) → see Diagram B above
1068
 * - "overlay" = poster-overlay on photo; "section" = poster-section-wrapper below description
1069
 * - ✗* = Aboutme/Ratings are md+ features but hidden when poster-section is hidden (short)
1070
 * - Back [←] shown in fullscreen-overlay mode; Close [×] shown in in-modal mode
1071
 * - Footer (Cancel/Reply): fixed at bottom in 1-col, inline in right column for 2-col
1072
 *
1073
 * STANDALONE PAGE CONTEXT
1074
 * =======================
1075
 * When used on standalone message pages (e.g., /message/123456):
1076
 * - Always uses 1-column layout regardless of viewport size
1077
 * - No 2-column layout even at xl-short viewports
1078
 * - Cancel button is hidden (no modal to cancel back to)
1079
 * - Reply button only (no Cancel) in footer
1080
 * - Page scrolls naturally to show all content
1081
 * - info-section height unconstrained (no internal scrollbars when room)
1082
 *
1083
 * Standalone detection: neither `inModal` nor `fullscreenOverlay` props are true
1084
 *
1085
 * TEST VIEWPORTS
1086
 * ==============
1087
 * Viewport sizes to test all layout variations, whitespace, and scrollbar behavior:
1088
 *
1089
 * Layout breakpoint tests:
1090
 * │ Viewport      │ Width │ Height│ Breakpoint │ Expected Layout                    │
1091
 * ├───────────────┼───────┼───────┼────────────┼────────────────────────────────────┤
1092
 * │ 375 × 600     │ xs    │ Short │ xs-short   │ 1-col, poster overlay on photo     │
1093
 * │ 375 × 800     │ xs    │ Tall  │ xs-tall    │ 1-col, poster in section           │
1094
 * │ 576 × 600     │ sm    │ Short │ sm-short   │ 1-col, poster overlay on photo     │
1095
 * │ 576 × 800     │ sm    │ Tall  │ sm-tall    │ 1-col, poster in section           │
1096
 * │ 768 × 600     │ md    │ Short │ md-short   │ 1-col, poster overlay, no aboutme  │
1097
 * │ 768 × 800     │ md    │ Tall  │ md-tall    │ 1-col, poster section + aboutme    │
1098
 * │ 992 × 600     │ lg    │ Short │ lg-short   │ 1-col, poster overlay, no aboutme  │
1099
 * │ 992 × 800     │ lg    │ Tall  │ lg-tall    │ 1-col, poster section + aboutme    │
1100
 * │ 1200 × 600    │ xl    │ Short │ xl-short   │ 2-col side-by-side, close button   │
1101
 * │ 1200 × 800    │ xl    │ Tall  │ xl-tall    │ 1-col, poster section + aboutme    │
1102
 *
1103
 * Whitespace and scrollbar edge cases:
1104
 * │ Viewport      │ Purpose                                                        │
1105
 * ├───────────────┼────────────────────────────────────────────────────────────────┤
1106
 * │ 375 × 700     │ Height breakpoint edge - verify no layout jump at boundary     │
1107
 * │ 375 × 701     │ Just above threshold - poster section should appear            │
1108
 * │ 768 × 500     │ Very short - info-section should scroll, photo fills space     │
1109
 * │ 768 × 900     │ Extra tall - verify no excess whitespace below footer          │
1110
 * │ 1200 × 700    │ xl at height boundary - verify 2-col triggers correctly        │
1111
 * │ 1200 × 701    │ xl just above threshold - should switch to 1-col               │
1112
 * │ 1400 × 600    │ Wide 2-col - verify photo doesn't stretch, no horizontal gap   │
1113
 * │ 1400 × 900    │ Wide tall - verify content fills space without whitespace      │
1114
 * │ 320 × 568     │ iPhone SE - smallest common device, check no overflow          │
1115
 * │ 414 × 896     │ iPhone 11 - common device, verify proper spacing               │
1116
 *
1117
 * Scrollbar presence tests (use with long description text):
1118
 * - 1-col short: info-section scrolls internally, page doesn't scroll
1119
 * - 1-col tall: info-section may scroll if content exceeds 50% height
1120
 * - 2-col: right column scrolls, left photo column doesn't
1121
 *
1122
 * BROKEN LAYOUT INDICATORS
1123
 * ========================
1124
 * A layout is broken if ANY of these conditions exist:
1125
 *
1126
 * 1. UNUSED WHITESPACE
1127
 *    - Large gaps between content sections (description vs buttons)
1128
 *    - Empty space in columns that should contain content
1129
 *    - Right column in 2-col mode showing only description when poster section should appear
1130
 *
1131
 * 2. SCROLLBAR PRESENT BUT SPACE UNUSED
1132
 *    - Scrollbar visible on right column but whitespace exists above buttons
1133
 *    - Content scrolls when viewport has room to display it all
1134
 *    - Indicates content is hidden/collapsed that should be visible
1135
 *
1136
 * 3. CSS SPECIFICITY CONFLICTS
1137
 *    - Parent context selector (`.in-modal &`) overrides media query
1138
 *    - Fix: Nest media query INSIDE the parent context for equal specificity
1139
 *    - Symptom: Layout doesn't change at breakpoint despite correct media query
1140
 *
1141
 * 4. DISPLAY MODE CONFLICTS
1142
 *    - `display: flex` on parent prevents `grid-template-columns` from working
1143
 *    - Must set `display: grid` at same specificity level as the flex rule
1144
 *    - Symptom: 2-column grid doesn't trigger even at correct viewport
1145
 *
1146
 * 5. VISIBILITY CONFLICTS
1147
 *    - Element hidden by one media query not re-shown by more specific query
1148
 *    - Example: `@media (max-height: 700px) { display: none }` hides in ALL short
1149
 *    - Must add: `@media (min-width: 1200px) and (max-height: 700px) { display: flex }`
1150
 *    - to show element specifically in 2-column mode
1151
 *
1152
 * 6. CONTENT SECTIONS MISSING
1153
 *    - In 2-col mode: poster section should appear in right column below description
1154
 *    - In 1-col tall: poster section should appear below photo
1155
 *    - In 1-col short: poster overlay should appear on photo
1156
 *    - If poster is nowhere visible = broken
1157
 *
1158
 * 7. MODAL/OVERLAY CONTEXT AWARENESS
1159
 *    - Rules for `.in-modal` and `.fullscreen-overlay` must be consistent
1160
 *    - Both contexts need identical 2-column breakpoint behavior
1161
 *
1162
 * 8. MAX-HEIGHT CONSTRAINTS HIDING CONTENT
1163
 *    - `max-height: 50%` on scrollable section may hide content below the fold
1164
 *    - Scrollbar present but whitespace visible = content hidden that should fit
1165
 *    - Fix: In 2-column mode, set `max-height: 100%` to use full available space
1166
 *    - Must be nested inside parent context selector for proper specificity
1167
 *    - Note: In 1-column tall mode (xl-tall), poster section may be below fold
1168
 *      and require scrolling - this is acceptable tradeoff to show more photo
1169
 *
1170
 * 9. FLEX CONTAINER WRAPPER ISSUES (Modal context)
1171
 *    - Bootstrap Vue modal-body uses `display: flex` but slot creates intermediate div
1172
 *    - Intermediate div has default `flex: 0 1 auto` so doesn't grow to fill parent
1173
 *    - Symptom: Content wrapper has smaller height than modal-body (e.g. 597px vs 928px)
1174
 *    - Debug: Walk DOM from wrapper to modal-body, check each element's flex/height
1175
 *    - Fix: Add wrapper class with `flex: 1; display: flex; flex-direction: column; min-height: 0`
1176
 *    - See MessageModal.vue `.message-content-wrapper` class
1177
 *
1178
 * Chrome DevTools MCP: mcp__chrome-devtools__resize_page(width, height)
1179
 */
1180

1181
/* Main wrapper - handles both modal and page contexts */
1182
.message-expanded-wrapper {
1183
  display: grid;
1184
  grid-template-rows: 1fr auto;
1185
  background: $color-white;
1186
  position: relative;
1187

1188
  /* When used inside a b-modal - let modal handle positioning */
1189
  &.in-modal {
1190
    position: relative;
1191
    width: 100%;
1192
    height: 100%;
1193
    flex: 1; /* Fill the flex container (modal-body uses display: flex) */
1194
    min-height: 0; /* Allow flex shrinking */
1195
  }
1196

1197
  /* When used as fullscreen overlay (mobile expand) */
1198
  &.fullscreen-overlay {
1199
    position: fixed;
1200
    top: 0;
1201
    left: 0;
1202
    width: 100%;
1203
    height: 100%;
1204
    z-index: 1050;
1205
    overflow: hidden;
1206
    overflow-x: hidden;
1207
  }
1208
}
1209

1210
/* Main content area - single column by default */
1211
.message-expanded-mobile {
1212
  display: contents;
1213
}
1214

1215
/* Two-column layout wrapper */
1216
.two-column-wrapper {
1217
  display: grid;
1218
  grid-template-columns: 1fr;
1219
  grid-template-rows: auto 1fr;
1220
  min-height: 0;
1221

1222
  /* Two-column layout only in modal/overlay contexts */
1223
  .in-modal &,
1224
  .fullscreen-overlay & {
1225
    /* Use flexbox for priority-based sizing: photo grows, info sizes to content */
1226
    display: flex;
1227
    flex-direction: column;
1228
    overflow: hidden;
1229

1230
    /* Two-column on xl+ screens with short height (≤700px) */
1231
    @media (min-width: 1200px) and (max-height: 700px) {
1232
      display: grid;
1233
      grid-template-columns: 1fr 1fr;
1234
      grid-template-rows: 1fr;
1235
      align-items: stretch;
1236
      height: 100%;
1237
    }
1238
  }
1239
}
1240

1241
/* Right column for two-column layout - only active in modal/overlay */
1242
.right-column {
1243
  display: contents;
1244

1245
  /* Two-column mode: use grid for precise control - only in modal/overlay */
1246
  .in-modal &,
1247
  .fullscreen-overlay & {
1248
    @media (min-width: 1200px) and (max-height: 700px) {
1249
      display: grid;
1250
      grid-template-rows: minmax(0, 1fr) auto;
1251
      min-width: 0;
1252
      height: 100%;
1253
      overflow-y: auto;
1254
      overscroll-behavior: contain;
1255
    }
1256
  }
1257
}
1258

1259
/* Inline reply section - hidden by default, shown in two-column layout (modal/overlay only) */
1260
.inline-reply-section {
1261
  display: none;
1262

1263
  .in-modal &,
1264
  .fullscreen-overlay & {
1265
    @media (min-width: 1200px) and (max-height: 700px) {
1266
      display: block;
1267
      flex-shrink: 0;
1268
      padding: 1rem;
1269
      border-top: 1px solid $color-gray-3;
1270
      background: $color-white;
1271
    }
1272
  }
1273
}
1274

1275
/* Photo Area - flexible height, fills available space */
1276
.photo-area {
1277
  position: relative;
1278
  width: 100%;
1279
  min-height: 150px;
1280
  max-height: 50vh;
1281
  overflow: hidden;
1282
  background: $color-gray--lighter;
1283
  cursor: pointer;
1284

1285
  /* In modal/fullscreen: grow to fill available space, shrink if needed */
1286
  .in-modal &,
1287
  .fullscreen-overlay & {
1288
    flex: 1 1 0;
1289
    max-height: none;
1290
    min-height: 100px;
1291
  }
1292

1293
  /* Two-column layout: photo fills full height of left column */
1294
  @media (min-width: 1200px) and (max-height: 700px) {
1295
    max-height: none;
1296
    height: 100%;
1297
  }
1298
}
1299

1300
// Photo container - positioned to fill photo-area
1301
.photo-container {
1302
  width: 100%;
1303
  height: 100%;
1304
  position: absolute;
1305
  top: 0;
1306
  left: 0;
1307
  overflow: hidden;
1308
}
1309

1310
// All image elements fill container
1311
.photo-container :deep(picture),
1312
.photo-container :deep(img) {
1313
  width: 100%;
1314
  height: 100%;
1315
  object-fit: cover;
1316
  display: block;
1317
}
1318

1319
// Ken Burns animation is in unscoped style block at end of file
1320

1321
// Stats pills inside title overlay
1322
.stats-pills {
1323
  display: flex;
1324
  justify-content: space-between;
1325
  align-items: center;
1326
  width: 100%;
1327
  margin-top: 0.5rem;
1328
}
1329

1330
.pills-left {
1331
  display: flex;
1332
  flex-wrap: wrap;
1333
  gap: 0.25rem;
1334
}
1335

1336
.pills-right {
1337
  display: flex;
1338
  flex-wrap: wrap;
1339
  gap: 0.25rem;
1340
}
1341

1342
.stat-pill {
1343
  display: inline-flex;
1344
  align-items: center;
1345
  gap: 0.15rem;
1346
  background: rgba(255, 255, 255, 0.25);
1347
  color: $color-white;
1348
  padding: 0.15rem 0.4rem;
1349
  border-radius: 1rem;
1350
  font-size: 0.7rem;
1351

1352
  &.clickable {
1353
    cursor: pointer;
1354
    background: $color-blue--bright;
1355
  }
1356
}
1357

1358
.delivery-maybe {
1359
  font-weight: bold;
1360
  font-size: 0.8rem;
1361
  margin-left: -0.1rem;
1362
}
1363

1364
// Status overlay image (promised/freegled)
1365
.status-overlay-image {
1366
  position: absolute;
1367
  z-index: 10;
1368
  transform: rotate(15deg);
1369
  top: 50%;
1370
  left: 50%;
1371
  width: 50%;
1372
  max-width: 200px;
1373
  margin-left: -25%;
1374
  margin-top: -15%;
1375
  pointer-events: none;
1376

1377
  /* When photo area is tall enough (>=300px), show larger overlay */
1378
  &--large {
1379
    width: 70%;
1380
    max-width: 450px;
1381
    margin-left: -35%;
1382
    margin-top: -20%;
1383
  }
1384
}
1385

1386
// Thumbnail carousel at top of photo area
1387
.thumbnail-carousel {
1388
  position: absolute;
1389
  top: 1rem;
1390
  left: 50%;
1391
  transform: translateX(-50%);
1392
  z-index: 11;
1393
  display: flex;
1394
  justify-content: center;
1395
  gap: 8px;
1396
  overflow-x: auto;
1397
  scrollbar-width: none;
1398
  -ms-overflow-style: none;
1399
  padding: 4px;
1400
  max-width: calc(100% - 120px);
1401

1402
  &::-webkit-scrollbar {
1403
    display: none;
1404
  }
1405
}
1406

1407
.thumbnail-item {
1408
  flex-shrink: 0;
1409
  width: 50px;
1410
  height: 50px;
1411
  border-radius: var(--radius-md, 0.5rem);
1412
  overflow: hidden;
1413
  border: 2px solid $color-white-opacity-50;
1414
  cursor: pointer;
1415
  transition: border-color var(--transition-normal),
1416
    transform var(--transition-normal);
1417

1418
  &.active {
1419
    border-color: $color-white;
1420
    transform: scale(1.1);
1421
  }
1422

1423
  &:not(.active):hover {
1424
    border-color: rgba(255, 255, 255, 0.8);
1425
  }
1426
}
1427

1428
.thumbnail-image {
1429
  width: 100%;
1430
  height: 100%;
1431
  object-fit: cover;
1432
}
1433

1434
// Back button on photo - only shown in fullscreen mode (not in modal)
1435
.back-button {
1436
  position: absolute;
1437
  top: 1rem;
1438
  left: 1rem;
1439
  width: 40px;
1440
  height: 40px;
1441
  border-radius: 50%;
1442
  background: $color-black-opacity-50;
1443
  border: none;
1444
  color: $color-white;
1445
  display: flex;
1446
  align-items: center;
1447
  justify-content: center;
1448
  cursor: pointer;
1449
  z-index: 12;
1450
  font-size: 1.2rem;
1451
  transition: all var(--transition-fast);
1452

1453
  &:hover {
1454
    background: $color-black-opacity-70;
1455
  }
1456

1457
  // Hide when inside a modal (use X close button instead)
1458
  .in-modal & {
1459
    display: none;
1460
  }
1461
}
1462

1463
// Close button for modal (positioned at modal top-right)
1464
.close-button {
1465
  display: none;
1466
  position: absolute;
1467
  top: 0;
1468
  right: 0;
1469
  width: 40px;
1470
  height: 40px;
1471
  border-radius: 50%;
1472
  background: $color-gray--darker;
1473
  border: 2px solid $color-white;
1474
  color: $color-white;
1475
  align-items: center;
1476
  justify-content: center;
1477
  cursor: pointer;
1478
  z-index: 100;
1479
  font-size: 1.2rem;
1480
  box-shadow: var(--shadow-md);
1481
  transition: all var(--transition-fast);
1482

1483
  &:hover {
1484
    background: var(--color-gray-600);
1485
  }
1486

1487
  // Show when inside a modal
1488
  .in-modal & {
1489
    display: flex;
1490
  }
1491
}
1492

1493
// Title overlay at bottom - more eye-catching
1494
.title-overlay {
1495
  position: absolute;
1496
  bottom: 0;
1497
  left: 0;
1498
  right: 0;
1499
  padding: 1rem 1rem 0.75rem;
1500
  background: linear-gradient(
1501
    to top,
1502
    rgba(0, 0, 0, 0.92) 0%,
1503
    rgba(0, 0, 0, 0.9) 8%,
1504
    rgba(0, 0, 0, 0.86) 16%,
1505
    rgba(0, 0, 0, 0.8) 24%,
1506
    rgba(0, 0, 0, 0.7) 32%,
1507
    rgba(0, 0, 0, 0.58) 42%,
1508
    rgba(0, 0, 0, 0.44) 52%,
1509
    rgba(0, 0, 0, 0.3) 62%,
1510
    rgba(0, 0, 0, 0.18) 72%,
1511
    rgba(0, 0, 0, 0.1) 82%,
1512
    rgba(0, 0, 0, 0.04) 92%,
1513
    rgba(0, 0, 0, 0) 100%
1514
  );
1515
  color: $color-white;
1516
  z-index: 10;
1517
  display: flex;
1518
  flex-direction: column;
1519
  align-items: stretch;
1520
}
1521

1522
.info-row {
1523
  display: flex;
1524
  justify-content: space-between;
1525
  align-items: center;
1526
  gap: 0.5rem;
1527
  margin-bottom: 0.25rem;
1528
}
1529

1530
.info-icons {
1531
  display: flex;
1532
  align-items: center;
1533
  gap: 0.35rem;
1534
  font-size: 0.7rem;
1535

1536
  @include media-breakpoint-up(md) {
1537
    gap: 0.5rem;
1538
    font-size: 0.85rem;
1539
  }
1540
}
1541

1542
.location,
1543
.time,
1544
.replies,
1545
.delivery,
1546
.deadline {
1547
  display: flex;
1548
  align-items: center;
1549
  gap: 0.2rem;
1550
  background: $color-black-opacity-50;
1551
  padding: 0.15rem 0.4rem;
1552
  backdrop-filter: blur(4px);
1553
}
1554

1555
.location {
1556
  cursor: pointer;
1557
}
1558

1559
.title-row {
1560
  width: 100%;
1561
  min-width: 0;
1562
}
1563

1564
.location-row {
1565
  display: flex;
1566
  align-items: center;
1567
  justify-content: space-between;
1568
  gap: 8px;
1569
  width: 100%;
1570
}
1571

1572
.photo-actions {
1573
  display: flex;
1574
  gap: 6px;
1575
  flex-shrink: 0;
1576
  margin-left: 8px;
1577
}
1578

1579
.photo-action-btn {
1580
  width: 28px;
1581
  height: 28px;
1582
  border-radius: 50%;
1583
  border: none;
1584
  background: rgba(255, 255, 255, 0.25);
1585
  color: $color-white;
1586
  display: flex;
1587
  align-items: center;
1588
  justify-content: center;
1589
  cursor: pointer;
1590
  transition: all var(--transition-normal);
1591

1592
  &:hover {
1593
    background: $color-white-opacity-50;
1594
  }
1595

1596
  svg {
1597
    font-size: 0.75rem;
1598
  }
1599
}
1600

1601
.title-tag {
1602
  font-size: 0.9rem !important;
1603
  white-space: nowrap !important;
1604
  flex-shrink: 0;
1605

1606
  @include media-breakpoint-up(md) {
1607
    font-size: 1rem !important;
1608
  }
1609
}
1610

1611
.title-subject {
1612
  display: block;
1613
  width: 100%;
1614
  font-size: clamp(1rem, 4vw, 1.25rem);
1615
  font-weight: 700;
1616
  line-height: 1.2;
1617
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
1618

1619
  @include media-breakpoint-up(md) {
1620
    font-size: clamp(1.25rem, 3.5vw, 1.75rem);
1621
  }
1622
}
1623

1624
.title-location {
1625
  font-size: 0.85rem;
1626
  opacity: 0.85;
1627
  margin-top: 0.15rem;
1628

1629
  @include media-breakpoint-up(md) {
1630
    font-size: 1rem;
1631
  }
1632
}
1633

1634
.photo-counter {
1635
  position: absolute;
1636
  top: 1rem;
1637
  right: 1rem;
1638
  background: $color-black-opacity-60;
1639
  color: $color-white;
1640
  padding: 0.25rem 0.6rem;
1641
  border-radius: 1rem;
1642
  font-size: 0.8rem;
1643
  z-index: 11;
1644
}
1645

1646
/* Info Section - scrollable with visible scrollbar */
1647
.info-section {
1648
  min-height: 0;
1649
  padding: 1rem;
1650

1651
  /* Visible scrollbar styling */
1652
  scrollbar-width: thin;
1653
  scrollbar-color: $color-gray--light $color-gray-3;
1654

1655
  &::-webkit-scrollbar {
1656
    width: 8px;
1657
  }
1658

1659
  &::-webkit-scrollbar-track {
1660
    background: $color-gray-3;
1661
  }
1662

1663
  &::-webkit-scrollbar-thumb {
1664
    background: $color-gray--light;
1665
    border-radius: var(--radius-sm, 0.375rem);
1666

1667
    &:hover {
1668
      background: $color-gray--base;
1669
    }
1670
  }
1671

1672
  /* In modal/fullscreen: constrain height and scroll if needed */
1673
  .in-modal &,
1674
  .fullscreen-overlay & {
1675
    flex: 0 0 auto;
1676
    max-height: 50%;
1677
    min-height: 0;
1678
    overflow-y: auto;
1679

1680
    /* Two-column layout: fill available space, scroll if needed - needs same specificity */
1681
    @media (min-width: 1200px) and (max-height: 700px) {
1682
      min-height: 0;
1683
      max-height: 100%;
1684
      overflow-y: auto;
1685
      overscroll-behavior: contain;
1686
    }
1687
  }
1688

1689
  /* Two-column layout on standalone pages: scroll if needed */
1690
  @media (min-width: 1200px) and (max-height: 700px) {
1691
    min-height: 0;
1692
    overflow-y: auto;
1693
  }
1694

1695
  /* In modal: make room for close button */
1696
  .in-modal & {
1697
    padding-top: 2.5rem;
1698
  }
1699
}
1700

1701
// Poster overlay on photo (shown on shorter screens, but NOT in 2-column mode)
1702
// Positioned above the title overlay (badges row)
1703
.poster-overlay {
1704
  display: none;
1705
  position: absolute;
1706
  bottom: 7rem; /* Above title-overlay which has ~6rem height */
1707
  right: 1rem;
1708
  background: rgba(255, 255, 255, 0.95);
1709
  backdrop-filter: blur(8px);
1710
  color: $color-gray--darker;
1711
  padding: 0.4rem 0.6rem;
1712
  z-index: 11;
1713
  text-decoration: none;
1714
  max-width: 60%;
1715
  align-items: center;
1716
  gap: 0.5rem;
1717
  box-shadow: var(--shadow-md);
1718
  border: 1px solid $color-gray-3;
1719
  cursor: pointer;
1720
  transition: all var(--transition-fast);
1721

1722
  &:hover {
1723
    background: $color-white;
1724
    color: $color-gray--darker;
1725
    text-decoration: none;
1726
  }
1727

1728
  /* Show on short screens (1-column only) */
1729
  @media (max-height: 700px) {
1730
    display: flex;
1731
  }
1732

1733
  /* Hide in 2-column mode - poster goes in section instead */
1734
  @media (min-width: 1200px) and (max-height: 700px) {
1735
    display: none;
1736
  }
1737
}
1738

1739
.poster-overlay-avatar-wrapper {
1740
  position: relative;
1741
  flex-shrink: 0;
1742
}
1743

1744
.poster-overlay-avatar {
1745
  :deep(.ProfileImage__container) {
1746
    width: 28px !important;
1747
    height: 28px !important;
1748
  }
1749
}
1750

1751
.supporter-badge-small {
1752
  position: absolute;
1753
  bottom: -2px;
1754
  right: -2px;
1755
  background: gold;
1756
  color: $color-white;
1757
  width: 14px;
1758
  height: 14px;
1759
  border-radius: 50%;
1760
  display: flex;
1761
  align-items: center;
1762
  justify-content: center;
1763
  border: 1px solid $color-white;
1764
  font-size: 0.45rem;
1765
}
1766

1767
.poster-overlay-info {
1768
  display: flex;
1769
  flex-direction: column;
1770
  min-width: 0;
1771
}
1772

1773
.poster-overlay-name {
1774
  font-size: 0.75rem;
1775
  font-weight: 600;
1776
  white-space: nowrap;
1777
  overflow: hidden;
1778
  text-overflow: ellipsis;
1779
}
1780

1781
.poster-overlay-stats {
1782
  display: flex;
1783
  gap: 0.5rem;
1784
  font-size: 0.65rem;
1785
  color: var(--color-gray-600);
1786
}
1787

1788
.poster-overlay-stat {
1789
  display: flex;
1790
  align-items: center;
1791
  gap: 0.15rem;
1792
}
1793

1794
.poster-overlay-separator {
1795
  color: $color-gray--base;
1796
}
1797

1798
.poster-overlay-chevron {
1799
  flex-shrink: 0;
1800
  color: var(--color-gray-600);
1801
  font-size: 0.9rem;
1802
  margin-left: auto;
1803
}
1804

1805
/* Section header with label on left, ID link on right */
1806
.section-header {
1807
  display: flex;
1808
  align-items: center;
1809
  justify-content: space-between;
1810
  margin-top: 1rem;
1811
  margin-bottom: 0.5rem;
1812
  border-bottom: 1px solid $color-gray-3;
1813
  padding-bottom: 0.25rem;
1814

1815
  /* In modal: add right padding to avoid close button overlap */
1816
  .in-modal & {
1817
    padding-right: 3rem;
1818
  }
1819

1820
  /* POSTED BY header hides on short screens where overlay is shown (1-column only) */
1821
  &--poster {
1822
    @media (max-height: 700px) {
1823
      display: none;
1824
    }
1825

1826
    /* Show in 2-column mode - poster section is always visible there */
1827
    @media (min-width: 1200px) and (max-height: 700px) {
1828
      display: flex;
1829
    }
1830
  }
1831
}
1832

1833
.section-header-text {
1834
  font-size: 0.7rem;
1835
  font-weight: 600;
1836
  color: $color-gray--base;
1837
  letter-spacing: 0.1em;
1838
}
1839

1840
.section-header-actions {
1841
  display: flex;
1842
  align-items: center;
1843
  gap: 0.5rem;
1844
}
1845

1846
.action-button {
1847
  display: inline-flex;
1848
  align-items: center;
1849
  gap: 0.25rem;
1850
  padding: 0.25rem 0.5rem;
1851
  border: 1px solid $color-gray--light;
1852
  background: $color-white;
1853
  color: var(--color-gray-600);
1854
  font-size: 0.7rem;
1855
  font-weight: 500;
1856
  cursor: pointer;
1857
  transition: all var(--transition-fast);
1858

1859
  &:hover {
1860
    background: $color-gray-3;
1861
    border-color: $color-gray--base;
1862
    color: $color-gray--darker;
1863
  }
1864

1865
  &--report {
1866
    color: $color-red--dark;
1867
    border-color: $color-red--light;
1868

1869
    &:hover {
1870
      background: $color-red--lighter;
1871
      border-color: $color-red--dark;
1872
    }
1873
  }
1874
}
1875

1876
.action-button-text {
1877
  display: none;
1878

1879
  @include media-breakpoint-up(md) {
1880
    display: inline;
1881
  }
1882
}
1883

1884
.section-header-name {
1885
  font-size: 0.7rem;
1886
  font-weight: 600;
1887
  color: $color-gray--darker;
1888
  margin-left: 0.35rem;
1889
}
1890

1891
.section-id-link {
1892
  font-size: 0.7rem;
1893
  font-weight: 500;
1894
  color: $color-gray--base;
1895
  text-decoration: none;
1896
  transition: all var(--transition-fast);
1897

1898
  &:hover {
1899
    color: var(--color-gray-600);
1900
    text-decoration: underline;
1901
  }
1902
}
1903

1904
/* Poster section wrapper - clickable to open profile modal */
1905
.poster-section-wrapper {
1906
  display: flex;
1907
  align-items: flex-start;
1908
  flex-wrap: wrap;
1909
  gap: 0.5rem;
1910
  padding: 0.75rem 1rem;
1911
  margin-top: 0.5rem;
1912
  text-decoration: none;
1913
  color: inherit;
1914
  background: $color-white;
1915
  border: 1px solid $color-gray--light;
1916
  border-left: 3px solid $color-info-fg;
1917
  cursor: pointer;
1918
  transition: all var(--transition-fast);
1919

1920
  &:hover {
1921
    text-decoration: none;
1922
    color: inherit;
1923
    background: $color-gray-3;
1924
  }
1925

1926
  /* Hide on short screens where overlay is shown (1-column only) */
1927
  @media (max-height: 700px) {
1928
    display: none;
1929
  }
1930

1931
  /* Show in 2-column mode - poster section is always visible there */
1932
  @media (min-width: 1200px) and (max-height: 700px) {
1933
    display: flex;
1934
  }
1935

1936
  /* Very narrow screens: stack vertically */
1937
  @media (max-width: 320px) {
1938
    flex-direction: column;
1939
    align-items: stretch;
1940
  }
1941
}
1942

1943
/* Poster aboutme - hidden on mobile, shown on tablet */
1944
.poster-aboutme {
1945
  display: none;
1946
  font-size: 0.85rem;
1947
  line-height: 1.5;
1948
  color: $color-gray--darker;
1949
  margin-top: 0.5rem;
1950
  font-style: italic;
1951
  -webkit-line-clamp: 6;
1952
  -webkit-box-orient: vertical;
1953
  overflow: hidden;
1954

1955
  &::before {
1956
    content: '"';
1957
  }
1958

1959
  &::after {
1960
    content: '"';
1961
  }
1962

1963
  @include media-breakpoint-up(md) {
1964
    display: -webkit-box;
1965
  }
1966
}
1967

1968
/* Poster ratings - hidden on mobile, shown on tablet */
1969
.poster-ratings {
1970
  display: none !important;
1971
  flex-shrink: 0;
1972

1973
  @include media-breakpoint-up(md) {
1974
    display: flex !important;
1975
  }
1976
}
1977

1978
.poster-avatar-wrapper {
1979
  position: relative;
1980
  flex-shrink: 0;
1981
}
1982

1983
.poster-avatar {
1984
  :deep(.ProfileImage__container) {
1985
    width: 48px !important;
1986
    height: 48px !important;
1987
  }
1988
}
1989

1990
.supporter-badge {
1991
  position: absolute;
1992
  bottom: 0;
1993
  right: 0;
1994
  background: gold;
1995
  color: $color-white;
1996
  width: 20px;
1997
  height: 20px;
1998
  border-radius: 50%;
1999
  display: flex;
2000
  align-items: center;
2001
  justify-content: center;
2002
  border: 2px solid $color-white;
2003
  font-size: 0.6rem;
2004
}
2005

2006
.poster-details {
2007
  flex: 1;
2008
  min-width: 0;
2009
  display: flex;
2010
  flex-direction: column;
2011
  gap: 0.15rem;
2012
  overflow: hidden;
2013
}
2014

2015
.poster-name {
2016
  font-size: 1rem;
2017
  font-weight: 600;
2018
  color: $color-gray--darker;
2019
  white-space: nowrap;
2020
  overflow: hidden;
2021
  text-overflow: ellipsis;
2022
}
2023

2024
.poster-stats {
2025
  display: flex;
2026
  align-items: center;
2027
  flex-wrap: wrap;
2028
  gap: 0.5rem;
2029
  font-size: 0.8rem;
2030
  color: var(--color-gray-600);
2031
}
2032

2033
.poster-distance,
2034
.poster-stat {
2035
  display: flex;
2036
  align-items: center;
2037
  gap: 0.2rem;
2038
}
2039

2040
.poster-stat-label {
2041
  display: none;
2042
  margin-left: 0.15rem;
2043

2044
  @include media-breakpoint-up(md) {
2045
    display: inline;
2046
  }
2047
}
2048

2049
.poster-stat-separator {
2050
  color: $color-gray--base;
2051
}
2052

2053
.poster-chevron {
2054
  flex-shrink: 0;
2055
  align-self: center;
2056
  color: var(--color-gray-600);
2057
  font-size: 1.25rem;
2058
  padding: 0.5rem;
2059
  margin-right: -0.5rem;
2060
}
2061

2062
// Description
2063
.description-section {
2064
  margin-bottom: 1rem;
2065
}
2066

2067
.description-content {
2068
  background: $color-white;
2069
  border: 1px solid $color-gray--light;
2070
  border-left: 3px solid $color-green--darker;
2071
  padding: 1rem;
2072
  font-size: 1rem;
2073
  line-height: 1.7;
2074
  color: $color-gray--darker;
2075
  position: relative;
2076
  overflow: hidden;
2077

2078
  /* Ensure at least 2 lines visible */
2079
  min-height: 3.4em;
2080

2081
  /* Faded "PROMISED" watermark behind the description text */
2082
  &--promised::before {
2083
    content: 'PROMISED';
2084
    position: absolute;
2085
    top: 50%;
2086
    left: 50%;
2087
    transform: translate(-50%, -50%) rotate(-15deg);
2088
    font-size: clamp(2rem, 8vw, 4rem);
2089
    font-weight: 900;
2090
    letter-spacing: 0.15em;
2091
    color: $color-orange--dark;
2092
    opacity: 0.25;
2093
    z-index: 0;
2094
    pointer-events: none;
2095
    white-space: nowrap;
2096
    user-select: none;
2097
  }
2098

2099
  /* Description text sits above the watermark */
2100
  &--promised > :deep(*) {
2101
    position: relative;
2102
    z-index: 1;
2103
  }
2104
}
2105

2106
.app-footer {
2107
  padding: 1rem;
2108
  border-top: 1px solid $color-gray-3;
2109
  background: $color-white;
2110
  flex-shrink: 0;
2111
  display: flex;
2112
  flex-direction: column;
2113
  justify-content: flex-end;
2114

2115
  /* Hide footer in two-column layout (modal/overlay only - reply is inline there) */
2116
  .in-modal &,
2117
  .fullscreen-overlay & {
2118
    @media (min-width: 1200px) and (max-height: 700px) {
2119
      display: none;
2120
    }
2121
  }
2122

2123
  /* Sticky ad adjustment - add bottom padding instead of positioning */
2124
  &.stickyAdRendered {
2125
    padding-bottom: calc(1rem + $sticky-banner-height-mobile);
2126

2127
    @media (min-height: $mobile-tall) {
2128
      padding-bottom: calc(1rem + $sticky-banner-height-mobile-tall);
2129
    }
2130

2131
    @media (min-height: $desktop-tall) {
2132
      padding-bottom: calc(1rem + $sticky-banner-height-desktop-tall);
2133
    }
2134
  }
2135
}
2136

2137
.footer-buttons {
2138
  display: flex;
2139
  gap: 0.75rem;
2140
  width: 100%;
2141
  max-width: 600px;
2142
  margin: 0 auto;
2143

2144
  .cancel-button,
2145
  .reply-button {
2146
    flex: 1;
2147
    width: auto !important;
2148
    display: flex !important;
2149
    justify-content: center;
2150
  }
2151

2152
  /* Mobile: only Reply button visible, full width */
2153
  @media (max-width: 767.98px) {
2154
    .cancel-button {
2155
      display: none !important;
2156
    }
2157

2158
    .reply-button {
2159
      width: 100% !important;
2160
    }
2161
  }
2162
}
2163

2164
/* When only Cancel button is shown (own posts), full width */
2165
.footer-buttons:has(.cancel-button:only-child) .cancel-button {
2166
  flex: 1;
2167
  width: 100% !important;
2168
}
2169

2170
.reply-expanded-section {
2171
  max-height: 70vh;
2172
  overflow-y: auto;
2173
}
2174

2175
.promised-notice {
2176
  text-align: center;
2177
  color: $color-orange--dark;
2178
  font-size: 0.85rem;
2179
  font-weight: 500;
2180

2181
  /* Desktop: more prominent banner-style notice */
2182
  @include media-breakpoint-up(md) {
2183
    font-size: 1.1rem;
2184
    font-weight: 700;
2185
    padding: 0.5rem 1rem;
2186
    background: rgba($color-orange--dark, 0.08);
2187
    border: 1px solid rgba($color-orange--dark, 0.25);
2188
    border-radius: var(--radius-sm, 0.375rem);
2189
  }
2190
}
2191

2192
// Fullscreen map viewer
2193
.fullscreen-map-viewer {
2194
  position: fixed;
2195
  top: 0;
2196
  left: 0;
2197
  right: 0;
2198
  bottom: 0;
2199
  background: $color-gray--lighter;
2200
  z-index: 10000;
2201
  display: flex;
2202
  flex-direction: column;
2203
}
2204

2205
.map-back-button {
2206
  position: absolute;
2207
  top: env(safe-area-inset-top, 0);
2208
  left: 0;
2209
  margin: 1rem;
2210
  width: 44px;
2211
  height: 44px;
2212
  border-radius: 50%;
2213
  background: rgba(255, 255, 255, 0.95);
2214
  border: none;
2215
  color: $color-gray--darker;
2216
  display: flex;
2217
  align-items: center;
2218
  justify-content: center;
2219
  cursor: pointer;
2220
  z-index: 10001;
2221
  font-size: 1.25rem;
2222
  box-shadow: var(--shadow-md);
2223

2224
  &:active {
2225
    background: $color-white;
2226
  }
2227
}
2228

2229
.fullscreen-map {
2230
  flex: 1;
2231
  width: 100%;
2232
  height: 100% !important;
2233

2234
  :deep(.leaflet-container) {
2235
    height: 100% !important;
2236
  }
2237
}
2238

2239
.map-hint {
2240
  position: absolute;
2241
  bottom: env(safe-area-inset-bottom, 0);
2242
  left: 0;
2243
  right: 0;
2244
  margin-bottom: 1rem;
2245
  padding: 0.5rem 1rem;
2246
  background: $color-white-opacity-90;
2247
  color: var(--color-gray-600);
2248
  font-size: 0.85rem;
2249
  text-align: center;
2250
  margin-left: 1rem;
2251
  margin-right: 1rem;
2252
  border-radius: var(--radius-md, 0.5rem);
2253
  box-shadow: var(--shadow-md);
2254
}
2255
</style>
2256

2257
<!-- Unscoped styles for Ken Burns - scoped :deep() doesn't penetrate NuxtPicture -->
2258
<style lang="scss">
2259
@import 'bootstrap/scss/functions';
2260
@import 'bootstrap/scss/variables';
2261
@import 'bootstrap/scss/mixins/_breakpoints';
2262

2263
/* Ken Burns effect - slow pan and zoom for ~10s then stop centered, mobile/tablet only */
2264
@keyframes kenburns {
2265
  0% {
2266
    transform: scale(1.15) translate(3%, 3%);
2267
  }
2268
  50% {
2269
    transform: scale(1.15) translate(-3%, -3%);
2270
  }
2271
  100% {
2272
    transform: scale(1) translate(0%, 0%);
2273
  }
2274
}
2275

2276
.photo-container.ken-burns img {
2277
  animation: kenburns 10s ease-in-out forwards;
2278
  will-change: transform;
2279
  transform-origin: center center;
2280

2281
  /* Disable on desktop (lg and up) */
2282
  @include media-breakpoint-up(lg) {
2283
    animation: none;
2284
    transform: none;
2285
  }
2286
}
2287

2288
@media (prefers-reduced-motion: reduce) {
2289
  .photo-container.ken-burns img {
2290
    animation: none !important;
2291
  }
2292
}
2293
</style>
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc