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

Freegle / iznik-nuxt3 / 233386f0-c5b8-40a0-b6ea-9b00821f8370

09 Feb 2026 09:00PM UTC coverage: 43.548% (-0.3%) from 43.859%
233386f0-c5b8-40a0-b6ea-9b00821f8370

push

circleci

CircleCI Auto-merge
Auto-merge master to production after successful tests - Original commit: fix: Prevent photo delete button from opening zoom modal on mobile

3737 of 8748 branches covered (42.72%)

Branch coverage included in aggregate %.

1683 of 3698 relevant lines covered (45.51%)

79.28 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

439
            <!-- Expanded reply section -->
440
            <div v-else class="reply-expanded-section">
441
              <NoticeMessage
442
                v-if="message.promised && !message.promisedtome"
×
443
                variant="warning"
444
                class="mb-2"
445
              >
446
                Already promised - you might not get it.
447
              </NoticeMessage>
×
448
              <client-only>
449
                <MessageReplySection
450
                  :id="id"
451
                  @close="replyExpanded = false"
×
452
                  @sent="sent"
453
                />
454
              </client-only>
455
            </div>
456
          </div>
457
        </div>
458
      </div>
459
    </div>
460

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

509
      <!-- Expanded reply section -->
510
      <div v-else class="reply-expanded-section">
511
        <NoticeMessage
512
          v-if="message.promised && !message.promisedtome"
64!
513
          variant="warning"
514
          class="mb-2"
515
        >
516
          Already promised - you might not get it.
517
        </NoticeMessage>
×
518
        <client-only>
519
          <MessageReplySection
520
            :id="id"
521
            @close="replyExpanded = false"
42✔
522
            @sent="sent"
523
          />
524
        </client-only>
525
      </div>
526
    </div>
527

528
    <!-- Map Modal - Full Screen -->
529
    <Teleport v-if="showMapModal" to="body">
119!
530
      <div class="fullscreen-map-viewer">
531
        <button class="map-back-button" @click="showMapModal = false">
×
532
          <v-icon icon="arrow-left" />
533
        </button>
534
        <client-only>
535
          <MessageMap
536
            v-if="validPosition"
×
537
            :home="home"
538
            :position="{ lat: message.lat, lng: message.lng }"
539
            class="fullscreen-map"
540
          />
541
        </client-only>
542
        <div class="map-hint">
543
          <v-icon icon="info-circle" /> Approximate location shown
×
544
        </div>
545
      </div>
546
    </Teleport>
547

548
    <!-- Photos Modal -->
549
    <MessagePhotosModal
550
      v-if="showMessagePhotosModal && attachmentCount"
238!
551
      :id="message.id"
552
      :initial-index="currentPhotoIndex"
553
      @hidden="showMessagePhotosModal = false"
×
554
    />
555

556
    <!-- Share Modal -->
557
    <MessageShareModal
558
      v-if="showShareModal && message.url"
238!
559
      :id="message.id"
560
      @hidden="showShareModal = false"
×
561
    />
562

563
    <!-- Profile Modal -->
564
    <ProfileModal
565
      v-if="showProfileModal && poster?.id"
238!
566
      :id="poster.id"
567
      @hidden="showProfileModal = false"
×
568
    />
569

570
    <!-- Report Modal -->
571
    <MessageReportModal
572
      v-if="showReportModal"
119!
573
      :id="id"
574
      @hidden="showReportModal = false"
18!
575
    />
576
  </div>
577
</template>
578

579
<script setup>
580
import {
581
  ref,
582
  computed,
583
  defineAsyncComponent,
584
  onMounted,
585
  onUnmounted,
586
} from 'vue'
587
import { useMiscStore } from '~/stores/misc'
588
import { useMobileStore } from '~/stores/mobile'
589
import { useMe } from '~/composables/useMe'
590
import { useMessageDisplay } from '~/composables/useMessageDisplay'
591
import { action } from '~/composables/useClientLog'
592
import MessageTextBody from '~/components/MessageTextBody'
593
import MessageTag from '~/components/MessageTag'
594
import NoticeMessage from '~/components/NoticeMessage'
595
import MessageReplySection from '~/components/MessageReplySection'
596
import ProfileImage from '~/components/ProfileImage'
597
import UserRatings from '~/components/UserRatings'
598
import { useModalHistory } from '~/composables/useModalHistory'
599

600
const MessageMap = defineAsyncComponent(() => import('~/components/MessageMap'))
21✔
601
const MessagePhotosModal = defineAsyncComponent(() =>
602
  import('~/components/MessagePhotosModal')
603
)
604
const MessageShareModal = defineAsyncComponent(() =>
605
  import('~/components/MessageShareModal')
606
)
607
const ProfileModal = defineAsyncComponent(() =>
608
  import('~/components/ProfileModal')
609
)
610
const MessageReportModal = defineAsyncComponent(() =>
611
  import('~/components/MessageReportModal')
612
)
613

614
const props = defineProps({
615
  id: {
616
    type: Number,
617
    required: true,
618
  },
619
  replyable: {
620
    type: Boolean,
621
    default: true,
622
  },
623
  hideClose: {
624
    type: Boolean,
625
    default: false,
626
  },
627
  actions: {
628
    type: Boolean,
629
    default: true,
630
  },
631
  inModal: {
632
    type: Boolean,
633
    default: false,
634
  },
635
  fullscreenOverlay: {
636
    type: Boolean,
637
    default: false,
638
  },
639
})
640

641
const emit = defineEmits(['zoom', 'close'])
642

643
const miscStore = useMiscStore()
644
const mobileStore = useMobileStore()
645
const { me, loggedIn } = useMe()
646

647
// Use shared composable for common message display logic
648
const {
649
  message,
650
  subjectItemName,
651
  subjectLocation,
652
  fromme,
653
  gotAttachments,
654
  attachmentCount,
655
  timeAgo,
656
  fullTimeAgo,
657
  distanceText,
658
  replyCount,
659
  replyTooltip,
660
  isOffer,
661
  formattedDeadline,
662
  deadlineTooltip,
663
  successfulText,
664
  placeholderClass,
665
  categoryIcon,
666
  poster,
667
} = useMessageDisplay(props.id)
668

669
const stickyAdRendered = computed(() => miscStore.stickyAdRendered)
670

671
// State
672
const replied = ref(false)
673
const replyExpanded = ref(false)
674
const mountTime = ref(null)
675
const showMapModal = ref(false)
676
const showShareModal = ref(false)
677
const showProfileModal = ref(false)
678
const showMessagePhotosModal = ref(false)
679
const showReportModal = ref(false)
680
const currentPhotoIndex = ref(0)
681
const containerRef = ref(null)
682
const photoAreaRef = ref(null)
683
const photoAreaHeight = ref(0)
684
const thumbnailsRef = ref(null)
685
const thumbnailTouchStartX = ref(0)
686
const thumbnailScrollStart = ref(0)
687
let thumbnailScrollInterval = null
688
let photoAreaObserver = null
21✔
689

690
// Computed (additional to composable)
691
const currentAttachment = computed(() => {
21✔
692
  return message.value?.attachments?.[currentPhotoIndex.value]
8!
693
})
694

695
const validPosition = computed(() => {
696
  return message.value?.lat || message.value?.lng
×
697
})
698

699
const home = computed(() => {
700
  if (me.value?.lat || me.value?.lng) {
×
701
    return { lat: me.value.lat, lng: me.value.lng }
702
  }
703
  return null
704
})
705

706
const prefersReducedMotion = computed(() => {
707
  if (typeof window === 'undefined') return false
4!
708
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches
709
})
710

711
// Defer ken-burns animation to after mount to avoid SSR hydration mismatch
712
const isMounted = ref(false)
713
const showKenBurns = computed(() => {
714
  return isMounted.value && !prefersReducedMotion.value
12✔
715
})
716

717
// Check if navbar-mobile teleport target exists (only after mount to avoid hydration mismatch)
718
const navbarMobileExists = computed(() => {
719
  if (!isMounted.value) return false
42✔
720
  return !!document.getElementById('navbar-mobile')
721
})
722

723
// Detect two-column layout (width >= xl breakpoint AND height <= 700px)
724
// Only evaluate after mount to avoid SSR hydration mismatch
725
const windowHeight = ref(0)
726
const isTwoColumnLayout = computed(() => {
727
  if (!isMounted.value) return false
80✔
728
  // Only use 2-column layout in modal or overlay - standalone pages use single column
729
  if (!props.inModal && !props.fullscreenOverlay) return false
42✔
730
  // Use miscStore breakpoint for width (xl = 1200px+)
731
  const isWideEnough = ['xl', 'xxl'].includes(miscStore.breakpoint)
4✔
732
  const isShortEnough = windowHeight.value <= 700
733
  return isWideEnough && isShortEnough
4✔
734
})
735

736
const photoAreaTallEnough = computed(() => photoAreaHeight.value >= 150)
737
const photoAreaIsLarge = computed(() => photoAreaHeight.value >= 300)
738

739
const posterAboutMe = computed(() => {
740
  const text = poster.value?.aboutme?.text
44!
741
  if (!text) return null
44✔
742
  return text
743
})
744

745
// Methods
746
function goBack() {
×
747
  emit('close')
×
748
}
749

750
function showPhotosModal() {
×
751
  if (gotAttachments.value) {
×
752
    showMessagePhotosModal.value = true
753
    emit('zoom')
754
  }
755
}
756

757
function showShare() {
×
758
  showShareModal.value = true
×
759
}
760

761
function showReport() {
×
762
  showReportModal.value = true
×
763
}
764

765
function selectPhoto(index) {
×
766
  currentPhotoIndex.value = index
×
767
}
768

769
function handleThumbnailClick(index) {
×
770
  if (index === currentPhotoIndex.value) {
×
771
    // Clicking the already selected thumbnail opens the photo viewer
772
    showPhotosModal()
773
  } else {
774
    selectPhoto(index)
775
  }
776
}
777

778
function onThumbnailTouchStart(e) {
×
779
  thumbnailTouchStartX.value = e.touches[0].clientX
×
780
  thumbnailScrollStart.value = thumbnailsRef.value?.scrollLeft || 0
×
781
}
782

783
function onThumbnailTouchMove(e) {
×
784
  if (!thumbnailsRef.value) return
×
785
  const deltaX = thumbnailTouchStartX.value - e.touches[0].clientX
×
786
  thumbnailsRef.value.scrollLeft = thumbnailScrollStart.value + deltaX
×
787
}
788

789
function onThumbnailTouchEnd() {
×
790
  // Swipe complete, scroll position is already set
791
}
792

793
function startThumbnailAutoScroll() {
21✔
794
  if (!thumbnailsRef.value || attachmentCount.value <= 1) return
21!
795

796
  // Wait a moment then scroll right slowly, then back
797
  setTimeout(() => {
798
    if (!thumbnailsRef.value) return
×
799
    const maxScroll =
×
800
      thumbnailsRef.value.scrollWidth - thumbnailsRef.value.clientWidth
801
    if (maxScroll <= 0) return
×
802

803
    // Animate scroll manually for smoother control
804
    const duration = 2000 // 2 seconds to scroll
×
805
    const startTime = performance.now()
806
    const startPos = 0
807

808
    function animateScroll(currentTime) {
×
809
      if (!thumbnailsRef.value) return
×
810
      const elapsed = currentTime - startTime
×
811
      const progress = Math.min(elapsed / duration, 1)
812
      // Ease in-out
813
      const eased =
814
        progress < 0.5
×
815
          ? 2 * progress * progress
816
          : 1 - Math.pow(-2 * progress + 2, 2) / 2
817
      thumbnailsRef.value.scrollLeft = startPos + maxScroll * eased
×
818

819
      if (progress < 1) {
×
820
        requestAnimationFrame(animateScroll)
821
      } else {
822
        // Pause then scroll back
823
        setTimeout(() => {
824
          if (!thumbnailsRef.value) return
×
825
          const backStartTime = performance.now()
×
826
          function animateBack(currentTime) {
×
827
            if (!thumbnailsRef.value) return
×
828
            const elapsed = currentTime - backStartTime
×
829
            const progress = Math.min(elapsed / duration, 1)
830
            const eased =
831
              progress < 0.5
×
832
                ? 2 * progress * progress
833
                : 1 - Math.pow(-2 * progress + 2, 2) / 2
834
            thumbnailsRef.value.scrollLeft = maxScroll - maxScroll * eased
×
835
            if (progress < 1) {
×
836
              requestAnimationFrame(animateBack)
837
            }
838
          }
839
          requestAnimationFrame(animateBack)
×
840
        }, 1000)
841
      }
842
    }
843
    requestAnimationFrame(animateScroll)
×
844
  }, 1000)
845
}
846

847
function stopThumbnailAutoScroll() {
848
  if (thumbnailScrollInterval) {
849
    clearInterval(thumbnailScrollInterval)
850
    thumbnailScrollInterval = null
851
  }
852
}
853

854
function expandReply() {
21✔
855
  console.log(
21✔
856
    'DEBUG expandReply called, replyable:',
857
    props.replyable,
858
    'replied:',
859
    replied.value,
860
    'fromme:',
861
    fromme.value
862
  )
863
  replyExpanded.value = true
864
}
865

866
function sent() {
7✔
867
  replyExpanded.value = false
7✔
868
  replied.value = true
869
  // Close after a brief delay so user sees confirmation
870
  setTimeout(() => {
871
    emit('close')
5✔
872
  }, 1500)
873
}
874

875
// Handle browser back button/swipe
876
useModalHistory(`message-${props.id}`, () => emit('close'), true)
21✔
877

878
function updateWindowHeight() {
21✔
879
  windowHeight.value = window.innerHeight
21✔
880
}
881

882
onMounted(() => {
883
  mountTime.value = Date.now()
21✔
884

885
  // Log mount for debugging mobile navigation issues.
886
  action('message_expanded_mount', {
887
    message_id: props.id,
888
    fullscreen_overlay: props.fullscreenOverlay,
889
    in_modal: props.inModal,
890
    breakpoint: miscStore.breakpoint,
891
  })
892

893
  // Prevent orientation changes while fullscreen overlay is open - keyboard opening
894
  // changes viewport dimensions which would incorrectly trigger landscape mode and
895
  // cause ScrollGrid to unmount/remount components, losing the modal state.
896
  if (props.fullscreenOverlay) {
21!
897
    miscStore.setFullscreenModalOpen(true)
898
  }
899

900
  // Enable ken-burns animation now that hydration is complete
901
  isMounted.value = true
902

903
  // Track window height for two-column layout detection (width via miscStore.breakpoint)
904
  updateWindowHeight()
905
  window.addEventListener('resize', updateWindowHeight)
906

907
  // Track photo area height for conditional overlay sizing
908
  if (photoAreaRef.value) {
42✔
909
    photoAreaHeight.value = photoAreaRef.value.clientHeight
910
    photoAreaObserver = new ResizeObserver((entries) => {
911
      photoAreaHeight.value = entries[0].contentRect.height
25✔
912
    })
913
    photoAreaObserver.observe(photoAreaRef.value)
914
  }
915

916
  // Start auto-scroll hint for thumbnail carousel
917
  startThumbnailAutoScroll()
918
})
919

920
onUnmounted(() => {
921
  const timeOpenMs = mountTime.value ? Date.now() - mountTime.value : null
14!
922

923
  action('message_expanded_unmount', {
14✔
924
    message_id: props.id,
925
    fullscreen_overlay: props.fullscreenOverlay,
926
    time_open_ms: timeOpenMs,
927
  })
928

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

960
  if (photoAreaObserver) {
28✔
961
    photoAreaObserver.disconnect()
962
    photoAreaObserver = null
963
  }
964

965
  stopThumbnailAutoScroll()
966
  window.removeEventListener('resize', updateWindowHeight)
967
})
968
</script>
969

970
<style scoped lang="scss">
971
@import 'bootstrap/scss/functions';
972
@import 'bootstrap/scss/variables';
973
@import 'bootstrap/scss/mixins/_breakpoints';
974
@import 'assets/css/sticky-banner.scss';
975
@import 'assets/css/_color-vars.scss';
976
@import 'assets/css/navbar.scss';
977

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

1171
/* Main wrapper - handles both modal and page contexts */
1172
.message-expanded-wrapper {
1173
  display: grid;
1174
  grid-template-rows: 1fr auto;
1175
  background: $color-white;
1176
  position: relative;
1177

1178
  /* When used inside a b-modal - let modal handle positioning */
1179
  &.in-modal {
1180
    position: relative;
1181
    width: 100%;
1182
    height: 100%;
1183
    flex: 1; /* Fill the flex container (modal-body uses display: flex) */
1184
    min-height: 0; /* Allow flex shrinking */
1185
  }
1186

1187
  /* When used as fullscreen overlay (mobile expand) */
1188
  &.fullscreen-overlay {
1189
    position: fixed;
1190
    top: 0;
1191
    left: 0;
1192
    width: 100%;
1193
    height: 100%;
1194
    z-index: 1050;
1195
    overflow: hidden;
1196
    overflow-x: hidden;
1197
  }
1198
}
1199

1200
/* Main content area - single column by default */
1201
.message-expanded-mobile {
1202
  display: contents;
1203
}
1204

1205
/* Two-column layout wrapper */
1206
.two-column-wrapper {
1207
  display: grid;
1208
  grid-template-columns: 1fr;
1209
  grid-template-rows: auto 1fr;
1210
  min-height: 0;
1211

1212
  /* Two-column layout only in modal/overlay contexts */
1213
  .in-modal &,
1214
  .fullscreen-overlay & {
1215
    /* Use flexbox for priority-based sizing: photo grows, info sizes to content */
1216
    display: flex;
1217
    flex-direction: column;
1218
    overflow: hidden;
1219

1220
    /* Two-column on xl+ screens with short height (≤700px) */
1221
    @media (min-width: 1200px) and (max-height: 700px) {
1222
      display: grid;
1223
      grid-template-columns: 1fr 1fr;
1224
      grid-template-rows: 1fr;
1225
      align-items: stretch;
1226
      height: 100%;
1227
    }
1228
  }
1229
}
1230

1231
/* Right column for two-column layout - only active in modal/overlay */
1232
.right-column {
1233
  display: contents;
1234

1235
  /* Two-column mode: use grid for precise control - only in modal/overlay */
1236
  .in-modal &,
1237
  .fullscreen-overlay & {
1238
    @media (min-width: 1200px) and (max-height: 700px) {
1239
      display: grid;
1240
      grid-template-rows: minmax(0, 1fr) auto;
1241
      min-width: 0;
1242
      height: 100%;
1243
      overflow-y: auto;
1244
      overscroll-behavior: contain;
1245
    }
1246
  }
1247
}
1248

1249
/* Inline reply section - hidden by default, shown in two-column layout (modal/overlay only) */
1250
.inline-reply-section {
1251
  display: none;
1252

1253
  .in-modal &,
1254
  .fullscreen-overlay & {
1255
    @media (min-width: 1200px) and (max-height: 700px) {
1256
      display: block;
1257
      flex-shrink: 0;
1258
      padding: 1rem;
1259
      border-top: 1px solid $color-gray-3;
1260
      background: $color-white;
1261
    }
1262
  }
1263
}
1264

1265
/* Photo Area - flexible height, fills available space */
1266
.photo-area {
1267
  position: relative;
1268
  width: 100%;
1269
  min-height: 150px;
1270
  max-height: 50vh;
1271
  overflow: hidden;
1272
  background: $color-gray--lighter;
1273
  cursor: pointer;
1274

1275
  /* In modal/fullscreen: grow to fill available space, shrink if needed */
1276
  .in-modal &,
1277
  .fullscreen-overlay & {
1278
    flex: 1 1 0;
1279
    max-height: none;
1280
    min-height: 100px;
1281
  }
1282

1283
  /* Two-column layout: photo fills full height of left column */
1284
  @media (min-width: 1200px) and (max-height: 700px) {
1285
    max-height: none;
1286
    height: 100%;
1287
  }
1288
}
1289

1290
// Photo container - positioned to fill photo-area
1291
.photo-container {
1292
  width: 100%;
1293
  height: 100%;
1294
  position: absolute;
1295
  top: 0;
1296
  left: 0;
1297
  overflow: hidden;
1298
}
1299

1300
// All image elements fill container
1301
.photo-container :deep(picture),
1302
.photo-container :deep(img) {
1303
  width: 100%;
1304
  height: 100%;
1305
  object-fit: cover;
1306
  display: block;
1307
}
1308

1309
// Ken Burns animation is in unscoped style block at end of file
1310

1311
// Stats pills inside title overlay
1312
.stats-pills {
1313
  display: flex;
1314
  justify-content: space-between;
1315
  align-items: center;
1316
  width: 100%;
1317
  margin-top: 0.5rem;
1318
}
1319

1320
.pills-left {
1321
  display: flex;
1322
  flex-wrap: wrap;
1323
  gap: 0.25rem;
1324
}
1325

1326
.pills-right {
1327
  display: flex;
1328
  flex-wrap: wrap;
1329
  gap: 0.25rem;
1330
}
1331

1332
.stat-pill {
1333
  display: inline-flex;
1334
  align-items: center;
1335
  gap: 0.15rem;
1336
  background: $color-white-opacity-25;
1337
  color: $color-white;
1338
  padding: 0.15rem 0.4rem;
1339
  border-radius: 1rem;
1340
  font-size: 0.7rem;
1341

1342
  &.clickable {
1343
    cursor: pointer;
1344
    background: $color-blue--bright;
1345
  }
1346
}
1347

1348
.delivery-maybe {
1349
  font-weight: bold;
1350
  font-size: 0.8rem;
1351
  margin-left: -0.1rem;
1352
}
1353

1354
// Status overlay image (promised/freegled)
1355
.status-overlay-image {
1356
  position: absolute;
1357
  z-index: 10;
1358
  transform: rotate(15deg);
1359
  top: 50%;
1360
  left: 50%;
1361
  width: 50%;
1362
  max-width: 200px;
1363
  margin-left: -25%;
1364
  margin-top: -15%;
1365
  pointer-events: none;
1366

1367
  /* When photo area is tall enough (>=300px), show larger overlay */
1368
  &--large {
1369
    width: 70%;
1370
    max-width: 450px;
1371
    margin-left: -35%;
1372
    margin-top: -20%;
1373
  }
1374
}
1375

1376
// Thumbnail carousel at top of photo area
1377
.thumbnail-carousel {
1378
  position: absolute;
1379
  top: 1rem;
1380
  left: 50%;
1381
  transform: translateX(-50%);
1382
  z-index: 11;
1383
  display: flex;
1384
  justify-content: center;
1385
  gap: 8px;
1386
  overflow-x: auto;
1387
  scrollbar-width: none;
1388
  -ms-overflow-style: none;
1389
  padding: 4px;
1390
  max-width: calc(100% - 120px);
1391

1392
  &::-webkit-scrollbar {
1393
    display: none;
1394
  }
1395
}
1396

1397
.thumbnail-item {
1398
  flex-shrink: 0;
1399
  width: 50px;
1400
  height: 50px;
1401
  border-radius: 8px;
1402
  overflow: hidden;
1403
  border: 2px solid $color-white-opacity-50;
1404
  cursor: pointer;
1405
  transition: border-color 0.2s, transform 0.2s;
1406

1407
  &.active {
1408
    border-color: $color-white;
1409
    transform: scale(1.1);
1410
  }
1411

1412
  &:not(.active):hover {
1413
    border-color: $color-white-opacity-80;
1414
  }
1415
}
1416

1417
.thumbnail-image {
1418
  width: 100%;
1419
  height: 100%;
1420
  object-fit: cover;
1421
}
1422

1423
// Back button on photo - only shown in fullscreen mode (not in modal)
1424
.back-button {
1425
  position: absolute;
1426
  top: 1rem;
1427
  left: 1rem;
1428
  width: 40px;
1429
  height: 40px;
1430
  border-radius: 50%;
1431
  background: $color-black-opacity-50;
1432
  border: none;
1433
  color: $color-white;
1434
  display: flex;
1435
  align-items: center;
1436
  justify-content: center;
1437
  cursor: pointer;
1438
  z-index: 12;
1439
  font-size: 1.2rem;
1440

1441
  &:hover {
1442
    background: $color-black-opacity-70;
1443
  }
1444

1445
  // Hide when inside a modal (use X close button instead)
1446
  .in-modal & {
1447
    display: none;
1448
  }
1449
}
1450

1451
// Close button for modal (positioned at modal top-right)
1452
.close-button {
1453
  display: none;
1454
  position: absolute;
1455
  top: 0;
1456
  right: 0;
1457
  width: 40px;
1458
  height: 40px;
1459
  border-radius: 50%;
1460
  background: $color-gray--darker;
1461
  border: 2px solid $color-white;
1462
  color: $color-white;
1463
  align-items: center;
1464
  justify-content: center;
1465
  cursor: pointer;
1466
  z-index: 100;
1467
  font-size: 1.2rem;
1468
  box-shadow: 0 2px 8px $color-black-opacity-30;
1469

1470
  &:hover {
1471
    background: $color-gray--dark;
1472
  }
1473

1474
  // Show when inside a modal
1475
  .in-modal & {
1476
    display: flex;
1477
  }
1478
}
1479

1480
// Title overlay at bottom - more eye-catching
1481
.title-overlay {
1482
  position: absolute;
1483
  bottom: 0;
1484
  left: 0;
1485
  right: 0;
1486
  padding: 1rem 1rem 0.75rem;
1487
  background: linear-gradient(
1488
    to top,
1489
    rgba(0, 0, 0, 0.92) 0%,
1490
    rgba(0, 0, 0, 0.9) 8%,
1491
    rgba(0, 0, 0, 0.86) 16%,
1492
    rgba(0, 0, 0, 0.8) 24%,
1493
    rgba(0, 0, 0, 0.7) 32%,
1494
    rgba(0, 0, 0, 0.58) 42%,
1495
    rgba(0, 0, 0, 0.44) 52%,
1496
    rgba(0, 0, 0, 0.3) 62%,
1497
    rgba(0, 0, 0, 0.18) 72%,
1498
    rgba(0, 0, 0, 0.1) 82%,
1499
    rgba(0, 0, 0, 0.04) 92%,
1500
    rgba(0, 0, 0, 0) 100%
1501
  );
1502
  color: $color-white;
1503
  z-index: 10;
1504
  display: flex;
1505
  flex-direction: column;
1506
  align-items: stretch;
1507
}
1508

1509
.info-row {
1510
  display: flex;
1511
  justify-content: space-between;
1512
  align-items: center;
1513
  gap: 0.5rem;
1514
  margin-bottom: 0.25rem;
1515
}
1516

1517
.info-icons {
1518
  display: flex;
1519
  align-items: center;
1520
  gap: 0.35rem;
1521
  font-size: 0.7rem;
1522

1523
  @include media-breakpoint-up(md) {
1524
    gap: 0.5rem;
1525
    font-size: 0.85rem;
1526
  }
1527
}
1528

1529
.location,
1530
.time,
1531
.replies,
1532
.delivery,
1533
.deadline {
1534
  display: flex;
1535
  align-items: center;
1536
  gap: 0.2rem;
1537
  background: $color-black-opacity-50;
1538
  padding: 0.15rem 0.4rem;
1539
  backdrop-filter: blur(4px);
1540
}
1541

1542
.location {
1543
  cursor: pointer;
1544
}
1545

1546
.title-row {
1547
  width: 100%;
1548
  min-width: 0;
1549
}
1550

1551
.location-row {
1552
  display: flex;
1553
  align-items: center;
1554
  justify-content: space-between;
1555
  gap: 8px;
1556
  width: 100%;
1557
}
1558

1559
.photo-actions {
1560
  display: flex;
1561
  gap: 6px;
1562
  flex-shrink: 0;
1563
  margin-left: 8px;
1564
}
1565

1566
.photo-action-btn {
1567
  width: 28px;
1568
  height: 28px;
1569
  border-radius: 50%;
1570
  border: none;
1571
  background: $color-white-opacity-25;
1572
  color: $color-white;
1573
  display: flex;
1574
  align-items: center;
1575
  justify-content: center;
1576
  cursor: pointer;
1577
  transition: all 0.2s;
1578

1579
  &:hover {
1580
    background: $color-white-opacity-50;
1581
  }
1582

1583
  svg {
1584
    font-size: 0.75rem;
1585
  }
1586
}
1587

1588
.title-tag {
1589
  font-size: 0.9rem !important;
1590
  white-space: nowrap !important;
1591
  flex-shrink: 0;
1592

1593
  @include media-breakpoint-up(md) {
1594
    font-size: 1rem !important;
1595
  }
1596
}
1597

1598
.title-subject {
1599
  display: block;
1600
  width: 100%;
1601
  font-size: clamp(1rem, 4vw, 1.25rem);
1602
  font-weight: 700;
1603
  line-height: 1.2;
1604
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
1605

1606
  @include media-breakpoint-up(md) {
1607
    font-size: clamp(1.25rem, 3.5vw, 1.75rem);
1608
  }
1609
}
1610

1611
.title-location {
1612
  font-size: 0.85rem;
1613
  opacity: 0.85;
1614
  margin-top: 0.15rem;
1615

1616
  @include media-breakpoint-up(md) {
1617
    font-size: 1rem;
1618
  }
1619
}
1620

1621
.photo-counter {
1622
  position: absolute;
1623
  top: 1rem;
1624
  right: 1rem;
1625
  background: $color-black-opacity-60;
1626
  color: $color-white;
1627
  padding: 0.25rem 0.6rem;
1628
  border-radius: 1rem;
1629
  font-size: 0.8rem;
1630
  z-index: 11;
1631
}
1632

1633
/* Info Section - scrollable with visible scrollbar */
1634
.info-section {
1635
  min-height: 0;
1636
  padding: 1rem;
1637

1638
  /* Visible scrollbar styling */
1639
  scrollbar-width: thin;
1640
  scrollbar-color: $color-gray--light $color-gray-3;
1641

1642
  &::-webkit-scrollbar {
1643
    width: 8px;
1644
  }
1645

1646
  &::-webkit-scrollbar-track {
1647
    background: $color-gray-3;
1648
  }
1649

1650
  &::-webkit-scrollbar-thumb {
1651
    background: $color-gray--light;
1652
    border-radius: 4px;
1653

1654
    &:hover {
1655
      background: $color-gray--base;
1656
    }
1657
  }
1658

1659
  /* In modal/fullscreen: constrain height and scroll if needed */
1660
  .in-modal &,
1661
  .fullscreen-overlay & {
1662
    flex: 0 0 auto;
1663
    max-height: 50%;
1664
    min-height: 0;
1665
    overflow-y: auto;
1666

1667
    /* Two-column layout: fill available space, scroll if needed - needs same specificity */
1668
    @media (min-width: 1200px) and (max-height: 700px) {
1669
      min-height: 0;
1670
      max-height: 100%;
1671
      overflow-y: auto;
1672
      overscroll-behavior: contain;
1673
    }
1674
  }
1675

1676
  /* Two-column layout on standalone pages: scroll if needed */
1677
  @media (min-width: 1200px) and (max-height: 700px) {
1678
    min-height: 0;
1679
    overflow-y: auto;
1680
  }
1681

1682
  /* In modal: make room for close button */
1683
  .in-modal & {
1684
    padding-top: 2.5rem;
1685
  }
1686
}
1687

1688
// Poster overlay on photo (shown on shorter screens, but NOT in 2-column mode)
1689
// Positioned above the title overlay (badges row)
1690
.poster-overlay {
1691
  display: none;
1692
  position: absolute;
1693
  bottom: 7rem; /* Above title-overlay which has ~6rem height */
1694
  right: 1rem;
1695
  background: $color-white-opacity-95;
1696
  backdrop-filter: blur(8px);
1697
  color: $color-gray--darker;
1698
  padding: 0.4rem 0.6rem;
1699
  z-index: 11;
1700
  text-decoration: none;
1701
  max-width: 60%;
1702
  align-items: center;
1703
  gap: 0.5rem;
1704
  box-shadow: 0 2px 8px $color-black-opacity-15;
1705
  border: 1px solid $color-gray-3;
1706
  cursor: pointer;
1707

1708
  &:hover {
1709
    background: $color-white;
1710
    color: $color-gray--darker;
1711
    text-decoration: none;
1712
  }
1713

1714
  /* Show on short screens (1-column only) */
1715
  @media (max-height: 700px) {
1716
    display: flex;
1717
  }
1718

1719
  /* Hide in 2-column mode - poster goes in section instead */
1720
  @media (min-width: 1200px) and (max-height: 700px) {
1721
    display: none;
1722
  }
1723
}
1724

1725
.poster-overlay-avatar-wrapper {
1726
  position: relative;
1727
  flex-shrink: 0;
1728
}
1729

1730
.poster-overlay-avatar {
1731
  :deep(.ProfileImage__container) {
1732
    width: 28px !important;
1733
    height: 28px !important;
1734
  }
1735
}
1736

1737
.supporter-badge-small {
1738
  position: absolute;
1739
  bottom: -2px;
1740
  right: -2px;
1741
  background: gold;
1742
  color: $color-white;
1743
  width: 14px;
1744
  height: 14px;
1745
  border-radius: 50%;
1746
  display: flex;
1747
  align-items: center;
1748
  justify-content: center;
1749
  border: 1px solid $color-white;
1750
  font-size: 0.45rem;
1751
}
1752

1753
.poster-overlay-info {
1754
  display: flex;
1755
  flex-direction: column;
1756
  min-width: 0;
1757
}
1758

1759
.poster-overlay-name {
1760
  font-size: 0.75rem;
1761
  font-weight: 600;
1762
  white-space: nowrap;
1763
  overflow: hidden;
1764
  text-overflow: ellipsis;
1765
}
1766

1767
.poster-overlay-stats {
1768
  display: flex;
1769
  gap: 0.5rem;
1770
  font-size: 0.65rem;
1771
  color: $color-gray--dark;
1772
}
1773

1774
.poster-overlay-stat {
1775
  display: flex;
1776
  align-items: center;
1777
  gap: 0.15rem;
1778
}
1779

1780
.poster-overlay-separator {
1781
  color: $color-gray--base;
1782
}
1783

1784
.poster-overlay-chevron {
1785
  flex-shrink: 0;
1786
  color: $color-gray--dark;
1787
  font-size: 0.9rem;
1788
  margin-left: auto;
1789
}
1790

1791
/* Section header with label on left, ID link on right */
1792
.section-header {
1793
  display: flex;
1794
  align-items: center;
1795
  justify-content: space-between;
1796
  margin-top: 1rem;
1797
  margin-bottom: 0.5rem;
1798
  border-bottom: 1px solid $color-gray-3;
1799
  padding-bottom: 0.25rem;
1800

1801
  /* In modal: add right padding to avoid close button overlap */
1802
  .in-modal & {
1803
    padding-right: 3rem;
1804
  }
1805

1806
  /* POSTED BY header hides on short screens where overlay is shown (1-column only) */
1807
  &--poster {
1808
    @media (max-height: 700px) {
1809
      display: none;
1810
    }
1811

1812
    /* Show in 2-column mode - poster section is always visible there */
1813
    @media (min-width: 1200px) and (max-height: 700px) {
1814
      display: flex;
1815
    }
1816
  }
1817
}
1818

1819
.section-header-text {
1820
  font-size: 0.7rem;
1821
  font-weight: 600;
1822
  color: $color-gray--base;
1823
  letter-spacing: 0.1em;
1824
}
1825

1826
.section-header-actions {
1827
  display: flex;
1828
  align-items: center;
1829
  gap: 0.5rem;
1830
}
1831

1832
.action-button {
1833
  display: inline-flex;
1834
  align-items: center;
1835
  gap: 0.25rem;
1836
  padding: 0.25rem 0.5rem;
1837
  border: 1px solid $color-gray--light;
1838
  background: $color-white;
1839
  color: $color-gray--dark;
1840
  font-size: 0.7rem;
1841
  font-weight: 500;
1842
  cursor: pointer;
1843
  transition: all 0.15s ease;
1844

1845
  &:hover {
1846
    background: $color-gray-3;
1847
    border-color: $color-gray--base;
1848
    color: $color-gray--darker;
1849
  }
1850

1851
  &--report {
1852
    color: $color-red--dark;
1853
    border-color: $color-red--light;
1854

1855
    &:hover {
1856
      background: $color-red--lighter;
1857
      border-color: $color-red--dark;
1858
    }
1859
  }
1860
}
1861

1862
.action-button-text {
1863
  display: none;
1864

1865
  @include media-breakpoint-up(md) {
1866
    display: inline;
1867
  }
1868
}
1869

1870
.section-header-name {
1871
  font-size: 0.7rem;
1872
  font-weight: 600;
1873
  color: $color-gray--darker;
1874
  margin-left: 0.35rem;
1875
}
1876

1877
.section-id-link {
1878
  font-size: 0.7rem;
1879
  font-weight: 500;
1880
  color: $color-gray--base;
1881
  text-decoration: none;
1882

1883
  &:hover {
1884
    color: $color-gray--dark;
1885
    text-decoration: underline;
1886
  }
1887
}
1888

1889
/* Poster section wrapper - clickable to open profile modal */
1890
.poster-section-wrapper {
1891
  display: flex;
1892
  align-items: flex-start;
1893
  flex-wrap: wrap;
1894
  gap: 0.5rem;
1895
  padding: 0.75rem 1rem;
1896
  margin-top: 0.5rem;
1897
  text-decoration: none;
1898
  color: inherit;
1899
  background: $color-white;
1900
  border: 1px solid $color-gray--light;
1901
  border-left: 3px solid $colour-info-fg;
1902
  cursor: pointer;
1903

1904
  &:hover {
1905
    text-decoration: none;
1906
    color: inherit;
1907
    background: $color-gray-3;
1908
  }
1909

1910
  /* Hide on short screens where overlay is shown (1-column only) */
1911
  @media (max-height: 700px) {
1912
    display: none;
1913
  }
1914

1915
  /* Show in 2-column mode - poster section is always visible there */
1916
  @media (min-width: 1200px) and (max-height: 700px) {
1917
    display: flex;
1918
  }
1919

1920
  /* Very narrow screens: stack vertically */
1921
  @media (max-width: 320px) {
1922
    flex-direction: column;
1923
    align-items: stretch;
1924
  }
1925
}
1926

1927
/* Poster aboutme - hidden on mobile, shown on tablet */
1928
.poster-aboutme {
1929
  display: none;
1930
  font-size: 0.85rem;
1931
  line-height: 1.5;
1932
  color: $color-gray--darker;
1933
  margin-top: 0.5rem;
1934
  font-style: italic;
1935
  -webkit-line-clamp: 6;
1936
  -webkit-box-orient: vertical;
1937
  overflow: hidden;
1938

1939
  &::before {
1940
    content: '"';
1941
  }
1942

1943
  &::after {
1944
    content: '"';
1945
  }
1946

1947
  @include media-breakpoint-up(md) {
1948
    display: -webkit-box;
1949
  }
1950
}
1951

1952
/* Poster ratings - hidden on mobile, shown on tablet */
1953
.poster-ratings {
1954
  display: none !important;
1955
  flex-shrink: 0;
1956

1957
  @include media-breakpoint-up(md) {
1958
    display: flex !important;
1959
  }
1960
}
1961

1962
.poster-avatar-wrapper {
1963
  position: relative;
1964
  flex-shrink: 0;
1965
}
1966

1967
.poster-avatar {
1968
  :deep(.ProfileImage__container) {
1969
    width: 48px !important;
1970
    height: 48px !important;
1971
  }
1972
}
1973

1974
.supporter-badge {
1975
  position: absolute;
1976
  bottom: 0;
1977
  right: 0;
1978
  background: gold;
1979
  color: $color-white;
1980
  width: 20px;
1981
  height: 20px;
1982
  border-radius: 50%;
1983
  display: flex;
1984
  align-items: center;
1985
  justify-content: center;
1986
  border: 2px solid $color-white;
1987
  font-size: 0.6rem;
1988
}
1989

1990
.poster-details {
1991
  flex: 1;
1992
  min-width: 0;
1993
  display: flex;
1994
  flex-direction: column;
1995
  gap: 0.15rem;
1996
  overflow: hidden;
1997
}
1998

1999
.poster-name {
2000
  font-size: 1rem;
2001
  font-weight: 600;
2002
  color: $color-gray--darker;
2003
  white-space: nowrap;
2004
  overflow: hidden;
2005
  text-overflow: ellipsis;
2006
}
2007

2008
.poster-stats {
2009
  display: flex;
2010
  align-items: center;
2011
  flex-wrap: wrap;
2012
  gap: 0.5rem;
2013
  font-size: 0.8rem;
2014
  color: $color-gray--dark;
2015
}
2016

2017
.poster-distance,
2018
.poster-stat {
2019
  display: flex;
2020
  align-items: center;
2021
  gap: 0.2rem;
2022
}
2023

2024
.poster-stat-label {
2025
  display: none;
2026
  margin-left: 0.15rem;
2027

2028
  @include media-breakpoint-up(md) {
2029
    display: inline;
2030
  }
2031
}
2032

2033
.poster-stat-separator {
2034
  color: $color-gray--base;
2035
}
2036

2037
.poster-chevron {
2038
  flex-shrink: 0;
2039
  align-self: center;
2040
  color: $color-gray--dark;
2041
  font-size: 1.25rem;
2042
  padding: 0.5rem;
2043
  margin-right: -0.5rem;
2044
}
2045

2046
// Description
2047
.description-section {
2048
  margin-bottom: 1rem;
2049
}
2050

2051
.description-content {
2052
  background: $color-white;
2053
  border: 1px solid $color-gray--light;
2054
  border-left: 3px solid $color-green--darker;
2055
  padding: 1rem;
2056
  font-size: 1rem;
2057
  line-height: 1.7;
2058
  color: $color-gray--darker;
2059
  position: relative;
2060
  overflow: hidden;
2061

2062
  /* Ensure at least 2 lines visible */
2063
  min-height: 3.4em;
2064

2065
  /* Faded "PROMISED" watermark behind the description text */
2066
  &--promised::before {
2067
    content: 'PROMISED';
2068
    position: absolute;
2069
    top: 50%;
2070
    left: 50%;
2071
    transform: translate(-50%, -50%) rotate(-15deg);
2072
    font-size: clamp(2rem, 8vw, 4rem);
2073
    font-weight: 900;
2074
    letter-spacing: 0.15em;
2075
    color: $color-orange--dark;
2076
    opacity: 0.25;
2077
    z-index: 0;
2078
    pointer-events: none;
2079
    white-space: nowrap;
2080
    user-select: none;
2081
  }
2082

2083
  /* Description text sits above the watermark */
2084
  &--promised > :deep(*) {
2085
    position: relative;
2086
    z-index: 1;
2087
  }
2088
}
2089

2090
.app-footer {
2091
  padding: 1rem;
2092
  border-top: 1px solid $color-gray-3;
2093
  background: $color-white;
2094
  flex-shrink: 0;
2095
  display: flex;
2096
  flex-direction: column;
2097
  justify-content: flex-end;
2098

2099
  /* Hide footer in two-column layout (modal/overlay only - reply is inline there) */
2100
  .in-modal &,
2101
  .fullscreen-overlay & {
2102
    @media (min-width: 1200px) and (max-height: 700px) {
2103
      display: none;
2104
    }
2105
  }
2106

2107
  /* Sticky ad adjustment - add bottom padding instead of positioning */
2108
  &.stickyAdRendered {
2109
    padding-bottom: calc(1rem + $sticky-banner-height-mobile);
2110

2111
    @media (min-height: $mobile-tall) {
2112
      padding-bottom: calc(1rem + $sticky-banner-height-mobile-tall);
2113
    }
2114

2115
    @media (min-height: $desktop-tall) {
2116
      padding-bottom: calc(1rem + $sticky-banner-height-desktop-tall);
2117
    }
2118
  }
2119
}
2120

2121
.footer-buttons {
2122
  display: flex;
2123
  gap: 0.75rem;
2124
  width: 100%;
2125
  max-width: 600px;
2126
  margin: 0 auto;
2127

2128
  .cancel-button,
2129
  .reply-button {
2130
    flex: 1;
2131
    width: auto !important;
2132
    display: flex !important;
2133
    justify-content: center;
2134
  }
2135

2136
  /* Mobile: only Reply button visible, full width */
2137
  @media (max-width: 767.98px) {
2138
    .cancel-button {
2139
      display: none !important;
2140
    }
2141

2142
    .reply-button {
2143
      width: 100% !important;
2144
    }
2145
  }
2146
}
2147

2148
/* When only Cancel button is shown (own posts), full width */
2149
.footer-buttons:has(.cancel-button:only-child) .cancel-button {
2150
  flex: 1;
2151
  width: 100% !important;
2152
}
2153

2154
.reply-expanded-section {
2155
  max-height: 70vh;
2156
  overflow-y: auto;
2157
}
2158

2159
.promised-notice {
2160
  text-align: center;
2161
  color: $color-orange--dark;
2162
  font-size: 0.85rem;
2163
  font-weight: 500;
2164

2165
  /* Desktop: more prominent banner-style notice */
2166
  @include media-breakpoint-up(md) {
2167
    font-size: 1.1rem;
2168
    font-weight: 700;
2169
    padding: 0.5rem 1rem;
2170
    background: rgba($color-orange--dark, 0.08);
2171
    border: 1px solid rgba($color-orange--dark, 0.25);
2172
    border-radius: 6px;
2173
  }
2174
}
2175

2176
// Fullscreen map viewer
2177
.fullscreen-map-viewer {
2178
  position: fixed;
2179
  top: 0;
2180
  left: 0;
2181
  right: 0;
2182
  bottom: 0;
2183
  background: $color-gray--lighter;
2184
  z-index: 10000;
2185
  display: flex;
2186
  flex-direction: column;
2187
}
2188

2189
.map-back-button {
2190
  position: absolute;
2191
  top: env(safe-area-inset-top, 0);
2192
  left: 0;
2193
  margin: 1rem;
2194
  width: 44px;
2195
  height: 44px;
2196
  border-radius: 50%;
2197
  background: $color-white-opacity-95;
2198
  border: none;
2199
  color: $color-gray--darker;
2200
  display: flex;
2201
  align-items: center;
2202
  justify-content: center;
2203
  cursor: pointer;
2204
  z-index: 10001;
2205
  font-size: 1.25rem;
2206
  box-shadow: 0 2px 8px $color-black-opacity-20;
2207

2208
  &:active {
2209
    background: $color-white;
2210
  }
2211
}
2212

2213
.fullscreen-map {
2214
  flex: 1;
2215
  width: 100%;
2216
  height: 100% !important;
2217

2218
  :deep(.leaflet-container) {
2219
    height: 100% !important;
2220
  }
2221
}
2222

2223
.map-hint {
2224
  position: absolute;
2225
  bottom: env(safe-area-inset-bottom, 0);
2226
  left: 0;
2227
  right: 0;
2228
  margin-bottom: 1rem;
2229
  padding: 0.5rem 1rem;
2230
  background: $color-white-opacity-90;
2231
  color: $color-gray--dark;
2232
  font-size: 0.85rem;
2233
  text-align: center;
2234
  margin-left: 1rem;
2235
  margin-right: 1rem;
2236
  border-radius: 8px;
2237
  box-shadow: 0 2px 8px $color-black-opacity-10;
2238
}
2239
</style>
2240

2241
<!-- Unscoped styles for Ken Burns - scoped :deep() doesn't penetrate NuxtPicture -->
2242
<style lang="scss">
2243
@import 'bootstrap/scss/functions';
2244
@import 'bootstrap/scss/variables';
2245
@import 'bootstrap/scss/mixins/_breakpoints';
2246

2247
/* Ken Burns effect - slow pan and zoom for ~10s then stop centered, mobile/tablet only */
2248
@keyframes kenburns {
2249
  0% {
2250
    transform: scale(1.15) translate(3%, 3%);
2251
  }
2252
  50% {
2253
    transform: scale(1.15) translate(-3%, -3%);
2254
  }
2255
  100% {
2256
    transform: scale(1) translate(0%, 0%);
2257
  }
2258
}
2259

2260
.photo-container.ken-burns img {
2261
  animation: kenburns 10s ease-in-out forwards;
2262
  will-change: transform;
2263
  transform-origin: center center;
2264

2265
  /* Disable on desktop (lg and up) */
2266
  @include media-breakpoint-up(lg) {
2267
    animation: none;
2268
    transform: none;
2269
  }
2270
}
2271

2272
@media (prefers-reduced-motion: reduce) {
2273
  .photo-container.ken-burns img {
2274
    animation: none !important;
2275
  }
2276
}
2277
</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