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

Freegle / iznik-nuxt3 / 9852a904-8759-432a-8699-8a8ec6be5344

27 Jan 2026 08:37PM UTC coverage: 44.873% (+1.5%) from 43.401%
9852a904-8759-432a-8699-8a8ec6be5344

push

circleci

CircleCI Auto-merge
Auto-merge master to production after successful tests - Original commit: copy: Update wanted post description to emphasize community

3683 of 8367 branches covered (44.02%)

Branch coverage included in aggregate %.

1678 of 3580 relevant lines covered (46.87%)

111.82 hits per line

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

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

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

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

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

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

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

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

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

427
            <!-- Expanded reply section -->
428
            <div v-else class="reply-expanded-section">
429
              <NoticeMessage
430
                v-if="message.promised && !message.promisedtome"
×
431
                variant="warning"
432
                class="mb-2"
433
              >
434
                Already promised - you might not get it.
435
              </NoticeMessage>
×
436
              <client-only>
437
                <MessageReplySection
438
                  :id="id"
439
                  @close="replyExpanded = false"
×
440
                  @sent="sent"
441
                />
442
              </client-only>
443
            </div>
444
          </div>
445
        </div>
446
      </div>
447
    </div>
448

449
    <!-- Fixed footer with Reply button - for single column layout only -->
450
    <div
451
      v-if="!isTwoColumnLayout"
102!
452
      class="app-footer"
453
      :class="{ expanded: replyExpanded, stickyAdRendered }"
454
    >
455
      <div v-if="!replyExpanded" class="w-100">
102✔
456
        <!-- Promised notice -->
457
        <div
458
          v-if="message.promised && !message.successful && replyable && !fromme"
186!
459
          class="promised-notice mb-2"
460
        >
461
          <v-icon icon="handshake" />
462
          {{ message.promisedtome ? 'Promised to you' : 'Already promised' }}
×
463
        </div>
464
        <div
465
          v-if="replyable && !replied && !message.successful"
465✔
466
          class="footer-buttons"
467
        >
468
          <b-button
469
            v-if="inModal || fullscreenOverlay"
271✔
470
            variant="secondary"
471
            size="lg"
472
            class="cancel-button"
473
            @click="goBack"
474
          >
475
            Cancel
476
          </b-button>
8✔
477
          <b-button
478
            v-if="!fromme"
93!
479
            variant="primary"
480
            size="lg"
481
            class="reply-button"
482
            @click="expandReply"
483
          >
484
            Reply
485
          </b-button>
52✔
486
        </div>
487
        <b-alert
488
          v-else-if="replied"
93!
489
          variant="info"
490
          :model-value="true"
491
          class="mb-0"
492
        >
493
          Message sent! Check your <nuxt-link to="/chats">Chats</nuxt-link>.
44✔
494
        </b-alert>
495
      </div>
496

497
      <!-- Expanded reply section -->
498
      <div v-else class="reply-expanded-section">
499
        <NoticeMessage
500
          v-if="message.promised && !message.promisedtome"
18!
501
          variant="warning"
502
          class="mb-2"
503
        >
504
          Already promised - you might not get it.
505
        </NoticeMessage>
×
506
        <client-only>
507
          <MessageReplySection
508
            :id="id"
509
            @close="replyExpanded = false"
42✔
510
            @sent="sent"
511
          />
512
        </client-only>
513
      </div>
514
    </div>
515

516
    <!-- Map Modal - Full Screen -->
517
    <Teleport v-if="showMapModal" to="body">
102!
518
      <div class="fullscreen-map-viewer">
519
        <button class="map-back-button" @click="showMapModal = false">
×
520
          <v-icon icon="arrow-left" />
521
        </button>
522
        <client-only>
523
          <MessageMap
524
            v-if="validPosition"
×
525
            :home="home"
526
            :position="{ lat: message.lat, lng: message.lng }"
527
            class="fullscreen-map"
528
          />
529
        </client-only>
530
        <div class="map-hint">
531
          <v-icon icon="info-circle" /> Approximate location shown
×
532
        </div>
533
      </div>
534
    </Teleport>
535

536
    <!-- Photos Modal -->
537
    <MessagePhotosModal
538
      v-if="showMessagePhotosModal && attachmentCount"
204!
539
      :id="message.id"
540
      :initial-index="currentPhotoIndex"
541
      @hidden="showMessagePhotosModal = false"
×
542
    />
543

544
    <!-- Share Modal -->
545
    <MessageShareModal
546
      v-if="showShareModal && message.url"
204!
547
      :id="message.id"
548
      @hidden="showShareModal = false"
×
549
    />
550

551
    <!-- Profile Modal -->
552
    <ProfileModal
553
      v-if="showProfileModal && poster?.id"
204!
554
      :id="poster.id"
555
      @hidden="showProfileModal = false"
×
556
    />
557

558
    <!-- Report Modal -->
559
    <MessageReportModal
560
      v-if="showReportModal"
102!
561
      :id="id"
562
      @hidden="showReportModal = false"
26!
563
    />
564
  </div>
565
</template>
566

567
<script setup>
568
import {
569
  ref,
570
  computed,
571
  defineAsyncComponent,
572
  onMounted,
573
  onUnmounted,
574
} from 'vue'
575
import { useMiscStore } from '~/stores/misc'
576
import { useMobileStore } from '~/stores/mobile'
577
import { useMe } from '~/composables/useMe'
578
import { useMessageDisplay } from '~/composables/useMessageDisplay'
579
import { action } from '~/composables/useClientLog'
580
import MessageTextBody from '~/components/MessageTextBody'
581
import MessageTag from '~/components/MessageTag'
582
import NoticeMessage from '~/components/NoticeMessage'
583
import MessageReplySection from '~/components/MessageReplySection'
584
import ProfileImage from '~/components/ProfileImage'
585
import UserRatings from '~/components/UserRatings'
586
import { useModalHistory } from '~/composables/useModalHistory'
587

588
const MessageMap = defineAsyncComponent(() => import('~/components/MessageMap'))
26✔
589
const MessagePhotosModal = defineAsyncComponent(() =>
590
  import('~/components/MessagePhotosModal')
591
)
592
const MessageShareModal = defineAsyncComponent(() =>
593
  import('~/components/MessageShareModal')
594
)
595
const ProfileModal = defineAsyncComponent(() =>
596
  import('~/components/ProfileModal')
597
)
598
const MessageReportModal = defineAsyncComponent(() =>
599
  import('~/components/MessageReportModal')
600
)
601

602
const props = defineProps({
603
  id: {
604
    type: Number,
605
    required: true,
606
  },
607
  replyable: {
608
    type: Boolean,
609
    default: true,
610
  },
611
  hideClose: {
612
    type: Boolean,
613
    default: false,
614
  },
615
  actions: {
616
    type: Boolean,
617
    default: true,
618
  },
619
  inModal: {
620
    type: Boolean,
621
    default: false,
622
  },
623
  fullscreenOverlay: {
624
    type: Boolean,
625
    default: false,
626
  },
627
})
628

629
const emit = defineEmits(['zoom', 'close'])
630

631
const miscStore = useMiscStore()
632
const mobileStore = useMobileStore()
633
const { me, loggedIn } = useMe()
634

635
// Use shared composable for common message display logic
636
const {
637
  message,
638
  subjectItemName,
639
  subjectLocation,
640
  fromme,
641
  gotAttachments,
642
  attachmentCount,
643
  timeAgo,
644
  fullTimeAgo,
645
  distanceText,
646
  replyCount,
647
  replyTooltip,
648
  isOffer,
649
  formattedDeadline,
650
  deadlineTooltip,
651
  successfulText,
652
  placeholderClass,
653
  categoryIcon,
654
  poster,
655
} = useMessageDisplay(props.id)
656

657
const stickyAdRendered = computed(() => miscStore.stickyAdRendered)
658

659
// State
660
const replied = ref(false)
661
const replyExpanded = ref(false)
662
const mountTime = ref(null)
663
const showMapModal = ref(false)
664
const showShareModal = ref(false)
665
const showProfileModal = ref(false)
666
const showMessagePhotosModal = ref(false)
667
const showReportModal = ref(false)
668
const currentPhotoIndex = ref(0)
669
const containerRef = ref(null)
670
const thumbnailsRef = ref(null)
671
const thumbnailTouchStartX = ref(0)
672
const thumbnailScrollStart = ref(0)
673
let thumbnailScrollInterval = null
674

675
// Computed (additional to composable)
676
const currentAttachment = computed(() => {
677
  return message.value?.attachments?.[currentPhotoIndex.value]
9!
678
})
679

680
const validPosition = computed(() => {
681
  return message.value?.lat || message.value?.lng
×
682
})
683

684
const home = computed(() => {
685
  if (me.value?.lat || me.value?.lng) {
×
686
    return { lat: me.value.lat, lng: me.value.lng }
687
  }
688
  return null
689
})
690

691
const prefersReducedMotion = computed(() => {
692
  if (typeof window === 'undefined') return false
4!
693
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches
694
})
695

696
// Defer ken-burns animation to after mount to avoid SSR hydration mismatch
697
const isMounted = ref(false)
698
const showKenBurns = computed(() => {
699
  return isMounted.value && !prefersReducedMotion.value
12✔
700
})
701

702
// Check if navbar-mobile teleport target exists (only after mount to avoid hydration mismatch)
703
const navbarMobileExists = computed(() => {
704
  if (!isMounted.value) return false
52✔
705
  return !!document.getElementById('navbar-mobile')
706
})
707

708
// Detect two-column layout (width >= xl breakpoint AND height <= 700px)
709
// Only evaluate after mount to avoid SSR hydration mismatch
710
const windowHeight = ref(0)
711
const isTwoColumnLayout = computed(() => {
712
  if (!isMounted.value) return false
100✔
713
  // Only use 2-column layout in modal or overlay - standalone pages use single column
714
  if (!props.inModal && !props.fullscreenOverlay) return false
52✔
715
  // Use miscStore breakpoint for width (xl = 1200px+)
716
  const isWideEnough = ['xl', 'xxl'].includes(miscStore.breakpoint)
4✔
717
  const isShortEnough = windowHeight.value <= 700
718
  return isWideEnough && isShortEnough
4✔
719
})
720

721
const posterAboutMe = computed(() => {
722
  const text = poster.value?.aboutme?.text
96!
723
  if (!text) return null
96✔
724
  return text
725
})
726

727
// Methods
728
function goBack() {
×
729
  emit('close')
×
730
}
731

732
function showPhotosModal() {
×
733
  if (gotAttachments.value) {
×
734
    showMessagePhotosModal.value = true
735
    emit('zoom')
736
  }
737
}
738

739
function showShare() {
×
740
  showShareModal.value = true
×
741
}
742

743
function showReport() {
×
744
  showReportModal.value = true
×
745
}
746

747
function selectPhoto(index) {
×
748
  currentPhotoIndex.value = index
×
749
}
750

751
function handleThumbnailClick(index) {
×
752
  if (index === currentPhotoIndex.value) {
×
753
    // Clicking the already selected thumbnail opens the photo viewer
754
    showPhotosModal()
755
  } else {
756
    selectPhoto(index)
757
  }
758
}
759

760
function onThumbnailTouchStart(e) {
×
761
  thumbnailTouchStartX.value = e.touches[0].clientX
×
762
  thumbnailScrollStart.value = thumbnailsRef.value?.scrollLeft || 0
×
763
}
764

765
function onThumbnailTouchMove(e) {
×
766
  if (!thumbnailsRef.value) return
×
767
  const deltaX = thumbnailTouchStartX.value - e.touches[0].clientX
×
768
  thumbnailsRef.value.scrollLeft = thumbnailScrollStart.value + deltaX
×
769
}
770

771
function onThumbnailTouchEnd() {
×
772
  // Swipe complete, scroll position is already set
773
}
774

775
function startThumbnailAutoScroll() {
26✔
776
  if (!thumbnailsRef.value || attachmentCount.value <= 1) return
26!
777

778
  // Wait a moment then scroll right slowly, then back
779
  setTimeout(() => {
780
    if (!thumbnailsRef.value) return
×
781
    const maxScroll =
×
782
      thumbnailsRef.value.scrollWidth - thumbnailsRef.value.clientWidth
783
    if (maxScroll <= 0) return
×
784

785
    // Animate scroll manually for smoother control
786
    const duration = 2000 // 2 seconds to scroll
×
787
    const startTime = performance.now()
788
    const startPos = 0
789

790
    function animateScroll(currentTime) {
×
791
      if (!thumbnailsRef.value) return
×
792
      const elapsed = currentTime - startTime
×
793
      const progress = Math.min(elapsed / duration, 1)
794
      // Ease in-out
795
      const eased =
796
        progress < 0.5
×
797
          ? 2 * progress * progress
798
          : 1 - Math.pow(-2 * progress + 2, 2) / 2
799
      thumbnailsRef.value.scrollLeft = startPos + maxScroll * eased
×
800

801
      if (progress < 1) {
×
802
        requestAnimationFrame(animateScroll)
803
      } else {
804
        // Pause then scroll back
805
        setTimeout(() => {
806
          if (!thumbnailsRef.value) return
×
807
          const backStartTime = performance.now()
×
808
          function animateBack(currentTime) {
×
809
            if (!thumbnailsRef.value) return
×
810
            const elapsed = currentTime - backStartTime
×
811
            const progress = Math.min(elapsed / duration, 1)
812
            const eased =
813
              progress < 0.5
×
814
                ? 2 * progress * progress
815
                : 1 - Math.pow(-2 * progress + 2, 2) / 2
816
            thumbnailsRef.value.scrollLeft = maxScroll - maxScroll * eased
×
817
            if (progress < 1) {
×
818
              requestAnimationFrame(animateBack)
819
            }
820
          }
821
          requestAnimationFrame(animateBack)
×
822
        }, 1000)
823
      }
824
    }
825
    requestAnimationFrame(animateScroll)
×
826
  }, 1000)
827
}
828

829
function stopThumbnailAutoScroll() {
830
  if (thumbnailScrollInterval) {
831
    clearInterval(thumbnailScrollInterval)
832
    thumbnailScrollInterval = null
833
  }
834
}
835

836
function expandReply() {
21✔
837
  console.log(
21✔
838
    'DEBUG expandReply called, replyable:',
839
    props.replyable,
840
    'replied:',
841
    replied.value,
842
    'fromme:',
843
    fromme.value
844
  )
845
  replyExpanded.value = true
846
}
847

848
function sent() {
7✔
849
  replyExpanded.value = false
7✔
850
  replied.value = true
851
  // Close after a brief delay so user sees confirmation
852
  setTimeout(() => {
853
    emit('close')
5✔
854
  }, 1500)
855
}
856

857
// Handle browser back button/swipe
858
useModalHistory(`message-${props.id}`, () => emit('close'), true)
26✔
859

860
function updateWindowHeight() {
26✔
861
  windowHeight.value = window.innerHeight
26✔
862
}
863

864
onMounted(() => {
865
  mountTime.value = Date.now()
26✔
866

867
  // Log mount for debugging mobile navigation issues.
868
  action('message_expanded_mount', {
869
    message_id: props.id,
870
    fullscreen_overlay: props.fullscreenOverlay,
871
    in_modal: props.inModal,
872
    breakpoint: miscStore.breakpoint,
873
  })
874

875
  // Prevent orientation changes while fullscreen overlay is open - keyboard opening
876
  // changes viewport dimensions which would incorrectly trigger landscape mode and
877
  // cause ScrollGrid to unmount/remount components, losing the modal state.
878
  if (props.fullscreenOverlay) {
26!
879
    miscStore.setFullscreenModalOpen(true)
880
  }
881

882
  // Enable ken-burns animation now that hydration is complete
883
  isMounted.value = true
884

885
  // Track window height for two-column layout detection (width via miscStore.breakpoint)
886
  updateWindowHeight()
887
  window.addEventListener('resize', updateWindowHeight)
888

889
  // Start auto-scroll hint for thumbnail carousel
890
  startThumbnailAutoScroll()
891
})
892

893
onUnmounted(() => {
894
  const timeOpenMs = mountTime.value ? Date.now() - mountTime.value : null
15!
895

896
  action('message_expanded_unmount', {
15✔
897
    message_id: props.id,
898
    fullscreen_overlay: props.fullscreenOverlay,
899
    time_open_ms: timeOpenMs,
900
  })
901

902
  // Clear the fullscreen modal flag so orientation detection resumes.
903
  // Re-check orientation since it may have changed while blocked.
904
  if (props.fullscreenOverlay) {
15!
905
    miscStore.setFullscreenModalOpen(false)
906
    // Use same detection method as OrientationFettler: Capacitor for app, matchMedia for web.
907
    if (mobileStore.isApp) {
×
908
      // In app, use Capacitor ScreenOrientation plugin.
909
      import('@capacitor/screen-orientation')
910
        .then(({ ScreenOrientation }) => {
911
          ScreenOrientation.orientation().then((orientation) => {
×
912
            const isLandscape =
×
913
              orientation.type === 'landscape-primary' ||
×
914
              orientation.type === 'landscape-secondary'
915
            miscStore.setLandscape(isLandscape)
×
916
          })
917
        })
918
        .catch(() => {
919
          // Fallback to matchMedia if plugin unavailable.
920
          if (typeof window !== 'undefined') {
×
921
            miscStore.setLandscape(
922
              window.matchMedia('(orientation: landscape)').matches
923
            )
924
          }
925
        })
926
    } else if (typeof window !== 'undefined') {
927
      miscStore.setLandscape(
928
        window.matchMedia('(orientation: landscape)').matches
929
      )
930
    }
931
  }
932

933
  stopThumbnailAutoScroll()
934
  window.removeEventListener('resize', updateWindowHeight)
935
})
936
</script>
937

938
<style scoped lang="scss">
939
@import 'bootstrap/scss/functions';
940
@import 'bootstrap/scss/variables';
941
@import 'bootstrap/scss/mixins/_breakpoints';
942
@import 'assets/css/sticky-banner.scss';
943
@import 'assets/css/_color-vars.scss';
944
@import 'assets/css/navbar.scss';
945

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

1139
/* Main wrapper - handles both modal and page contexts */
1140
.message-expanded-wrapper {
1141
  display: grid;
1142
  grid-template-rows: 1fr auto;
1143
  background: $color-white;
1144
  position: relative;
1145

1146
  /* When used inside a b-modal - let modal handle positioning */
1147
  &.in-modal {
1148
    position: relative;
1149
    width: 100%;
1150
    height: 100%;
1151
    flex: 1; /* Fill the flex container (modal-body uses display: flex) */
1152
    min-height: 0; /* Allow flex shrinking */
1153
  }
1154

1155
  /* When used as fullscreen overlay (mobile expand) */
1156
  &.fullscreen-overlay {
1157
    position: fixed;
1158
    top: 0;
1159
    left: 0;
1160
    width: 100%;
1161
    height: 100%;
1162
    z-index: 1050;
1163
    overflow: hidden;
1164
    overflow-x: hidden;
1165
  }
1166
}
1167

1168
/* Main content area - single column by default */
1169
.message-expanded-mobile {
1170
  display: contents;
1171
}
1172

1173
/* Two-column layout wrapper */
1174
.two-column-wrapper {
1175
  display: grid;
1176
  grid-template-columns: 1fr;
1177
  grid-template-rows: auto 1fr;
1178
  min-height: 0;
1179

1180
  /* Two-column layout only in modal/overlay contexts */
1181
  .in-modal &,
1182
  .fullscreen-overlay & {
1183
    /* Use flexbox for priority-based sizing: photo grows, info sizes to content */
1184
    display: flex;
1185
    flex-direction: column;
1186
    overflow: hidden;
1187

1188
    /* Two-column on xl+ screens with short height (≤700px) */
1189
    @media (min-width: 1200px) and (max-height: 700px) {
1190
      display: grid;
1191
      grid-template-columns: 1fr 1fr;
1192
      grid-template-rows: 1fr;
1193
      align-items: stretch;
1194
      height: 100%;
1195
    }
1196
  }
1197
}
1198

1199
/* Right column for two-column layout - only active in modal/overlay */
1200
.right-column {
1201
  display: contents;
1202

1203
  /* Two-column mode: use grid for precise control - only in modal/overlay */
1204
  .in-modal &,
1205
  .fullscreen-overlay & {
1206
    @media (min-width: 1200px) and (max-height: 700px) {
1207
      display: grid;
1208
      grid-template-rows: minmax(0, 1fr) auto;
1209
      min-width: 0;
1210
      height: 100%;
1211
      overflow-y: auto;
1212
      overscroll-behavior: contain;
1213
    }
1214
  }
1215
}
1216

1217
/* Inline reply section - hidden by default, shown in two-column layout (modal/overlay only) */
1218
.inline-reply-section {
1219
  display: none;
1220

1221
  .in-modal &,
1222
  .fullscreen-overlay & {
1223
    @media (min-width: 1200px) and (max-height: 700px) {
1224
      display: block;
1225
      flex-shrink: 0;
1226
      padding: 1rem;
1227
      border-top: 1px solid $color-gray-3;
1228
      background: $color-white;
1229
    }
1230
  }
1231
}
1232

1233
/* Photo Area - flexible height, fills available space */
1234
.photo-area {
1235
  position: relative;
1236
  width: 100%;
1237
  min-height: 150px;
1238
  max-height: 50vh;
1239
  overflow: hidden;
1240
  background: $color-gray--lighter;
1241
  cursor: pointer;
1242

1243
  /* In modal/fullscreen: grow to fill available space, shrink if needed */
1244
  .in-modal &,
1245
  .fullscreen-overlay & {
1246
    flex: 1 1 0;
1247
    max-height: none;
1248
    min-height: 100px;
1249
  }
1250

1251
  /* Two-column layout: photo fills full height of left column */
1252
  @media (min-width: 1200px) and (max-height: 700px) {
1253
    max-height: none;
1254
    height: 100%;
1255
  }
1256
}
1257

1258
// Photo container - positioned to fill photo-area
1259
.photo-container {
1260
  width: 100%;
1261
  height: 100%;
1262
  position: absolute;
1263
  top: 0;
1264
  left: 0;
1265
  overflow: hidden;
1266
}
1267

1268
// All image elements fill container
1269
.photo-container :deep(picture),
1270
.photo-container :deep(img) {
1271
  width: 100%;
1272
  height: 100%;
1273
  object-fit: cover;
1274
  display: block;
1275
}
1276

1277
// Ken Burns animation is in unscoped style block at end of file
1278

1279
// Stats pills inside title overlay
1280
.stats-pills {
1281
  display: flex;
1282
  justify-content: space-between;
1283
  align-items: center;
1284
  width: 100%;
1285
  margin-top: 0.5rem;
1286
}
1287

1288
.pills-left {
1289
  display: flex;
1290
  flex-wrap: wrap;
1291
  gap: 0.25rem;
1292
}
1293

1294
.pills-right {
1295
  display: flex;
1296
  flex-wrap: wrap;
1297
  gap: 0.25rem;
1298
}
1299

1300
.stat-pill {
1301
  display: inline-flex;
1302
  align-items: center;
1303
  gap: 0.15rem;
1304
  background: $color-white-opacity-25;
1305
  color: $color-white;
1306
  padding: 0.15rem 0.4rem;
1307
  border-radius: 1rem;
1308
  font-size: 0.7rem;
1309

1310
  &.clickable {
1311
    cursor: pointer;
1312
    background: $color-blue--bright;
1313
  }
1314
}
1315

1316
.delivery-maybe {
1317
  font-weight: bold;
1318
  font-size: 0.8rem;
1319
  margin-left: -0.1rem;
1320
}
1321

1322
// Status overlay image (promised/freegled)
1323
.status-overlay-image {
1324
  position: absolute;
1325
  z-index: 10;
1326
  transform: rotate(15deg);
1327
  top: 50%;
1328
  left: 50%;
1329
  width: 50%;
1330
  max-width: 200px;
1331
  margin-left: -25%;
1332
  margin-top: -15%;
1333
  pointer-events: none;
1334
}
1335

1336
// Thumbnail carousel at top of photo area
1337
.thumbnail-carousel {
1338
  position: absolute;
1339
  top: 1rem;
1340
  left: 50%;
1341
  transform: translateX(-50%);
1342
  z-index: 11;
1343
  display: flex;
1344
  justify-content: center;
1345
  gap: 8px;
1346
  overflow-x: auto;
1347
  scrollbar-width: none;
1348
  -ms-overflow-style: none;
1349
  padding: 4px;
1350
  max-width: calc(100% - 120px);
1351

1352
  &::-webkit-scrollbar {
1353
    display: none;
1354
  }
1355
}
1356

1357
.thumbnail-item {
1358
  flex-shrink: 0;
1359
  width: 50px;
1360
  height: 50px;
1361
  border-radius: 8px;
1362
  overflow: hidden;
1363
  border: 2px solid $color-white-opacity-50;
1364
  cursor: pointer;
1365
  transition: border-color 0.2s, transform 0.2s;
1366

1367
  &.active {
1368
    border-color: $color-white;
1369
    transform: scale(1.1);
1370
  }
1371

1372
  &:not(.active):hover {
1373
    border-color: $color-white-opacity-80;
1374
  }
1375
}
1376

1377
.thumbnail-image {
1378
  width: 100%;
1379
  height: 100%;
1380
  object-fit: cover;
1381
}
1382

1383
// Back button on photo - only shown in fullscreen mode (not in modal)
1384
.back-button {
1385
  position: absolute;
1386
  top: 1rem;
1387
  left: 1rem;
1388
  width: 40px;
1389
  height: 40px;
1390
  border-radius: 50%;
1391
  background: $color-black-opacity-50;
1392
  border: none;
1393
  color: $color-white;
1394
  display: flex;
1395
  align-items: center;
1396
  justify-content: center;
1397
  cursor: pointer;
1398
  z-index: 12;
1399
  font-size: 1.2rem;
1400

1401
  &:hover {
1402
    background: $color-black-opacity-70;
1403
  }
1404

1405
  // Hide when inside a modal (use X close button instead)
1406
  .in-modal & {
1407
    display: none;
1408
  }
1409
}
1410

1411
// Close button for modal (positioned at modal top-right)
1412
.close-button {
1413
  display: none;
1414
  position: absolute;
1415
  top: 0;
1416
  right: 0;
1417
  width: 40px;
1418
  height: 40px;
1419
  border-radius: 50%;
1420
  background: $color-gray--darker;
1421
  border: 2px solid $color-white;
1422
  color: $color-white;
1423
  align-items: center;
1424
  justify-content: center;
1425
  cursor: pointer;
1426
  z-index: 100;
1427
  font-size: 1.2rem;
1428
  box-shadow: 0 2px 8px $color-black-opacity-30;
1429

1430
  &:hover {
1431
    background: $color-gray--dark;
1432
  }
1433

1434
  // Show when inside a modal
1435
  .in-modal & {
1436
    display: flex;
1437
  }
1438
}
1439

1440
// Title overlay at bottom - more eye-catching
1441
.title-overlay {
1442
  position: absolute;
1443
  bottom: 0;
1444
  left: 0;
1445
  right: 0;
1446
  padding: 1rem 1rem 0.75rem;
1447
  background: linear-gradient(
1448
    to top,
1449
    rgba(0, 0, 0, 0.92) 0%,
1450
    rgba(0, 0, 0, 0.9) 8%,
1451
    rgba(0, 0, 0, 0.86) 16%,
1452
    rgba(0, 0, 0, 0.8) 24%,
1453
    rgba(0, 0, 0, 0.7) 32%,
1454
    rgba(0, 0, 0, 0.58) 42%,
1455
    rgba(0, 0, 0, 0.44) 52%,
1456
    rgba(0, 0, 0, 0.3) 62%,
1457
    rgba(0, 0, 0, 0.18) 72%,
1458
    rgba(0, 0, 0, 0.1) 82%,
1459
    rgba(0, 0, 0, 0.04) 92%,
1460
    rgba(0, 0, 0, 0) 100%
1461
  );
1462
  color: $color-white;
1463
  z-index: 10;
1464
  display: flex;
1465
  flex-direction: column;
1466
  align-items: stretch;
1467
}
1468

1469
.info-row {
1470
  display: flex;
1471
  justify-content: space-between;
1472
  align-items: center;
1473
  gap: 0.5rem;
1474
  margin-bottom: 0.25rem;
1475
}
1476

1477
.info-icons {
1478
  display: flex;
1479
  align-items: center;
1480
  gap: 0.35rem;
1481
  font-size: 0.7rem;
1482

1483
  @include media-breakpoint-up(md) {
1484
    gap: 0.5rem;
1485
    font-size: 0.85rem;
1486
  }
1487
}
1488

1489
.location,
1490
.time,
1491
.replies,
1492
.delivery,
1493
.deadline {
1494
  display: flex;
1495
  align-items: center;
1496
  gap: 0.2rem;
1497
  background: $color-black-opacity-50;
1498
  padding: 0.15rem 0.4rem;
1499
  backdrop-filter: blur(4px);
1500
}
1501

1502
.location {
1503
  cursor: pointer;
1504
}
1505

1506
.title-row {
1507
  width: 100%;
1508
  min-width: 0;
1509
}
1510

1511
.location-row {
1512
  display: flex;
1513
  align-items: center;
1514
  justify-content: space-between;
1515
  gap: 8px;
1516
  width: 100%;
1517
}
1518

1519
.photo-actions {
1520
  display: flex;
1521
  gap: 6px;
1522
  flex-shrink: 0;
1523
  margin-left: 8px;
1524
}
1525

1526
.photo-action-btn {
1527
  width: 28px;
1528
  height: 28px;
1529
  border-radius: 50%;
1530
  border: none;
1531
  background: $color-white-opacity-25;
1532
  color: $color-white;
1533
  display: flex;
1534
  align-items: center;
1535
  justify-content: center;
1536
  cursor: pointer;
1537
  transition: all 0.2s;
1538

1539
  &:hover {
1540
    background: $color-white-opacity-50;
1541
  }
1542

1543
  svg {
1544
    font-size: 0.75rem;
1545
  }
1546
}
1547

1548
.title-tag {
1549
  font-size: 0.9rem !important;
1550
  white-space: nowrap !important;
1551
  flex-shrink: 0;
1552

1553
  @include media-breakpoint-up(md) {
1554
    font-size: 1rem !important;
1555
  }
1556
}
1557

1558
.title-subject {
1559
  display: block;
1560
  width: 100%;
1561
  font-size: clamp(1rem, 4vw, 1.25rem);
1562
  font-weight: 700;
1563
  line-height: 1.2;
1564
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
1565

1566
  @include media-breakpoint-up(md) {
1567
    font-size: clamp(1.25rem, 3.5vw, 1.75rem);
1568
  }
1569
}
1570

1571
.title-location {
1572
  font-size: 0.85rem;
1573
  opacity: 0.85;
1574
  margin-top: 0.15rem;
1575

1576
  @include media-breakpoint-up(md) {
1577
    font-size: 1rem;
1578
  }
1579
}
1580

1581
.photo-counter {
1582
  position: absolute;
1583
  top: 1rem;
1584
  right: 1rem;
1585
  background: $color-black-opacity-60;
1586
  color: $color-white;
1587
  padding: 0.25rem 0.6rem;
1588
  border-radius: 1rem;
1589
  font-size: 0.8rem;
1590
  z-index: 11;
1591
}
1592

1593
/* Info Section - scrollable with visible scrollbar */
1594
.info-section {
1595
  min-height: 0;
1596
  padding: 1rem;
1597

1598
  /* Visible scrollbar styling */
1599
  scrollbar-width: thin;
1600
  scrollbar-color: $color-gray--light $color-gray-3;
1601

1602
  &::-webkit-scrollbar {
1603
    width: 8px;
1604
  }
1605

1606
  &::-webkit-scrollbar-track {
1607
    background: $color-gray-3;
1608
  }
1609

1610
  &::-webkit-scrollbar-thumb {
1611
    background: $color-gray--light;
1612
    border-radius: 4px;
1613

1614
    &:hover {
1615
      background: $color-gray--base;
1616
    }
1617
  }
1618

1619
  /* In modal/fullscreen: constrain height and scroll if needed */
1620
  .in-modal &,
1621
  .fullscreen-overlay & {
1622
    flex: 0 0 auto;
1623
    max-height: 50%;
1624
    min-height: 0;
1625
    overflow-y: auto;
1626

1627
    /* Two-column layout: fill available space, scroll if needed - needs same specificity */
1628
    @media (min-width: 1200px) and (max-height: 700px) {
1629
      min-height: 0;
1630
      max-height: 100%;
1631
      overflow-y: auto;
1632
      overscroll-behavior: contain;
1633
    }
1634
  }
1635

1636
  /* Two-column layout on standalone pages: scroll if needed */
1637
  @media (min-width: 1200px) and (max-height: 700px) {
1638
    min-height: 0;
1639
    overflow-y: auto;
1640
  }
1641

1642
  /* In modal: make room for close button */
1643
  .in-modal & {
1644
    padding-top: 2.5rem;
1645
  }
1646
}
1647

1648
// Poster overlay on photo (shown on shorter screens, but NOT in 2-column mode)
1649
// Positioned above the title overlay (badges row)
1650
.poster-overlay {
1651
  display: none;
1652
  position: absolute;
1653
  bottom: 7rem; /* Above title-overlay which has ~6rem height */
1654
  right: 1rem;
1655
  background: $color-white-opacity-95;
1656
  backdrop-filter: blur(8px);
1657
  color: $color-gray--darker;
1658
  padding: 0.4rem 0.6rem;
1659
  z-index: 11;
1660
  text-decoration: none;
1661
  max-width: 60%;
1662
  align-items: center;
1663
  gap: 0.5rem;
1664
  box-shadow: 0 2px 8px $color-black-opacity-15;
1665
  border: 1px solid $color-gray-3;
1666
  cursor: pointer;
1667

1668
  &:hover {
1669
    background: $color-white;
1670
    color: $color-gray--darker;
1671
    text-decoration: none;
1672
  }
1673

1674
  /* Show on short screens (1-column only) */
1675
  @media (max-height: 700px) {
1676
    display: flex;
1677
  }
1678

1679
  /* Hide in 2-column mode - poster goes in section instead */
1680
  @media (min-width: 1200px) and (max-height: 700px) {
1681
    display: none;
1682
  }
1683
}
1684

1685
.poster-overlay-avatar-wrapper {
1686
  position: relative;
1687
  flex-shrink: 0;
1688
}
1689

1690
.poster-overlay-avatar {
1691
  :deep(.ProfileImage__container) {
1692
    width: 28px !important;
1693
    height: 28px !important;
1694
  }
1695
}
1696

1697
.supporter-badge-small {
1698
  position: absolute;
1699
  bottom: -2px;
1700
  right: -2px;
1701
  background: gold;
1702
  color: $color-white;
1703
  width: 14px;
1704
  height: 14px;
1705
  border-radius: 50%;
1706
  display: flex;
1707
  align-items: center;
1708
  justify-content: center;
1709
  border: 1px solid $color-white;
1710
  font-size: 0.45rem;
1711
}
1712

1713
.poster-overlay-info {
1714
  display: flex;
1715
  flex-direction: column;
1716
  min-width: 0;
1717
}
1718

1719
.poster-overlay-name {
1720
  font-size: 0.75rem;
1721
  font-weight: 600;
1722
  white-space: nowrap;
1723
  overflow: hidden;
1724
  text-overflow: ellipsis;
1725
}
1726

1727
.poster-overlay-stats {
1728
  display: flex;
1729
  gap: 0.5rem;
1730
  font-size: 0.65rem;
1731
  color: $color-gray--dark;
1732
}
1733

1734
.poster-overlay-stat {
1735
  display: flex;
1736
  align-items: center;
1737
  gap: 0.15rem;
1738
}
1739

1740
.poster-overlay-separator {
1741
  color: $color-gray--base;
1742
}
1743

1744
.poster-overlay-chevron {
1745
  flex-shrink: 0;
1746
  color: $color-gray--dark;
1747
  font-size: 0.9rem;
1748
  margin-left: auto;
1749
}
1750

1751
/* Section header with label on left, ID link on right */
1752
.section-header {
1753
  display: flex;
1754
  align-items: center;
1755
  justify-content: space-between;
1756
  margin-top: 1rem;
1757
  margin-bottom: 0.5rem;
1758
  border-bottom: 1px solid $color-gray-3;
1759
  padding-bottom: 0.25rem;
1760

1761
  /* In modal: add right padding to avoid close button overlap */
1762
  .in-modal & {
1763
    padding-right: 3rem;
1764
  }
1765

1766
  /* POSTED BY header hides on short screens where overlay is shown (1-column only) */
1767
  &--poster {
1768
    @media (max-height: 700px) {
1769
      display: none;
1770
    }
1771

1772
    /* Show in 2-column mode - poster section is always visible there */
1773
    @media (min-width: 1200px) and (max-height: 700px) {
1774
      display: flex;
1775
    }
1776
  }
1777
}
1778

1779
.section-header-text {
1780
  font-size: 0.7rem;
1781
  font-weight: 600;
1782
  color: $color-gray--base;
1783
  letter-spacing: 0.1em;
1784
}
1785

1786
.section-header-actions {
1787
  display: flex;
1788
  align-items: center;
1789
  gap: 0.5rem;
1790
}
1791

1792
.action-button {
1793
  display: inline-flex;
1794
  align-items: center;
1795
  gap: 0.25rem;
1796
  padding: 0.25rem 0.5rem;
1797
  border: 1px solid $color-gray--light;
1798
  background: $color-white;
1799
  color: $color-gray--dark;
1800
  font-size: 0.7rem;
1801
  font-weight: 500;
1802
  cursor: pointer;
1803
  transition: all 0.15s ease;
1804

1805
  &:hover {
1806
    background: $color-gray-3;
1807
    border-color: $color-gray--base;
1808
    color: $color-gray--darker;
1809
  }
1810

1811
  &--report {
1812
    color: $color-red--dark;
1813
    border-color: $color-red--light;
1814

1815
    &:hover {
1816
      background: $color-red--lighter;
1817
      border-color: $color-red--dark;
1818
    }
1819
  }
1820
}
1821

1822
.action-button-text {
1823
  display: none;
1824

1825
  @include media-breakpoint-up(md) {
1826
    display: inline;
1827
  }
1828
}
1829

1830
.section-id-link {
1831
  font-size: 0.7rem;
1832
  font-weight: 500;
1833
  color: $color-gray--base;
1834
  text-decoration: none;
1835

1836
  &:hover {
1837
    color: $color-gray--dark;
1838
    text-decoration: underline;
1839
  }
1840
}
1841

1842
/* Poster section wrapper - clickable to open profile modal */
1843
.poster-section-wrapper {
1844
  display: flex;
1845
  align-items: flex-start;
1846
  flex-wrap: wrap;
1847
  gap: 0.5rem;
1848
  padding: 0.75rem 1rem;
1849
  margin-top: 0.5rem;
1850
  text-decoration: none;
1851
  color: inherit;
1852
  background: $color-white;
1853
  border: 1px solid $color-gray--light;
1854
  border-left: 3px solid $colour-info-fg;
1855
  cursor: pointer;
1856

1857
  &:hover {
1858
    text-decoration: none;
1859
    color: inherit;
1860
    background: $color-gray-3;
1861
  }
1862

1863
  /* Hide on short screens where overlay is shown (1-column only) */
1864
  @media (max-height: 700px) {
1865
    display: none;
1866
  }
1867

1868
  /* Show in 2-column mode - poster section is always visible there */
1869
  @media (min-width: 1200px) and (max-height: 700px) {
1870
    display: flex;
1871
  }
1872

1873
  /* Very narrow screens: stack vertically */
1874
  @media (max-width: 320px) {
1875
    flex-direction: column;
1876
    align-items: stretch;
1877
  }
1878
}
1879

1880
/* Poster aboutme - hidden on mobile, shown on tablet */
1881
.poster-aboutme {
1882
  display: none;
1883
  font-size: 0.85rem;
1884
  line-height: 1.5;
1885
  color: $color-gray--darker;
1886
  margin-top: 0.5rem;
1887
  font-style: italic;
1888
  -webkit-line-clamp: 6;
1889
  -webkit-box-orient: vertical;
1890
  overflow: hidden;
1891

1892
  &::before {
1893
    content: '"';
1894
  }
1895

1896
  &::after {
1897
    content: '"';
1898
  }
1899

1900
  @include media-breakpoint-up(md) {
1901
    display: -webkit-box;
1902
  }
1903
}
1904

1905
/* Poster ratings - hidden on mobile, shown on tablet */
1906
.poster-ratings {
1907
  display: none !important;
1908
  flex-shrink: 0;
1909

1910
  @include media-breakpoint-up(md) {
1911
    display: flex !important;
1912
  }
1913
}
1914

1915
.poster-avatar-wrapper {
1916
  position: relative;
1917
  flex-shrink: 0;
1918
}
1919

1920
.poster-avatar {
1921
  :deep(.ProfileImage__container) {
1922
    width: 48px !important;
1923
    height: 48px !important;
1924
  }
1925
}
1926

1927
.supporter-badge {
1928
  position: absolute;
1929
  bottom: 0;
1930
  right: 0;
1931
  background: gold;
1932
  color: $color-white;
1933
  width: 20px;
1934
  height: 20px;
1935
  border-radius: 50%;
1936
  display: flex;
1937
  align-items: center;
1938
  justify-content: center;
1939
  border: 2px solid $color-white;
1940
  font-size: 0.6rem;
1941
}
1942

1943
.poster-details {
1944
  flex: 1;
1945
  min-width: 0;
1946
  display: flex;
1947
  flex-direction: column;
1948
  gap: 0.15rem;
1949
  overflow: hidden;
1950
}
1951

1952
.poster-name {
1953
  font-size: 1rem;
1954
  font-weight: 600;
1955
  color: $color-gray--darker;
1956
  white-space: nowrap;
1957
  overflow: hidden;
1958
  text-overflow: ellipsis;
1959
}
1960

1961
.poster-stats {
1962
  display: flex;
1963
  align-items: center;
1964
  flex-wrap: wrap;
1965
  gap: 0.5rem;
1966
  font-size: 0.8rem;
1967
  color: $color-gray--dark;
1968
}
1969

1970
.poster-distance,
1971
.poster-stat {
1972
  display: flex;
1973
  align-items: center;
1974
  gap: 0.2rem;
1975
}
1976

1977
.poster-stat-label {
1978
  display: none;
1979
  margin-left: 0.15rem;
1980

1981
  @include media-breakpoint-up(md) {
1982
    display: inline;
1983
  }
1984
}
1985

1986
.poster-stat-separator {
1987
  color: $color-gray--base;
1988
}
1989

1990
.poster-chevron {
1991
  flex-shrink: 0;
1992
  align-self: center;
1993
  color: $color-gray--dark;
1994
  font-size: 1.25rem;
1995
  padding: 0.5rem;
1996
  margin-right: -0.5rem;
1997
}
1998

1999
// Description
2000
.description-section {
2001
  margin-bottom: 1rem;
2002
}
2003

2004
.description-content {
2005
  background: $color-white;
2006
  border: 1px solid $color-gray--light;
2007
  border-left: 3px solid $color-green--darker;
2008
  padding: 1rem;
2009
  font-size: 1rem;
2010
  line-height: 1.7;
2011
  color: $color-gray--darker;
2012

2013
  /* Ensure at least 2 lines visible */
2014
  min-height: 3.4em;
2015
}
2016

2017
.app-footer {
2018
  padding: 1rem;
2019
  border-top: 1px solid $color-gray-3;
2020
  background: $color-white;
2021
  flex-shrink: 0;
2022
  display: flex;
2023
  flex-direction: column;
2024
  justify-content: flex-end;
2025

2026
  /* Hide footer in two-column layout (modal/overlay only - reply is inline there) */
2027
  .in-modal &,
2028
  .fullscreen-overlay & {
2029
    @media (min-width: 1200px) and (max-height: 700px) {
2030
      display: none;
2031
    }
2032
  }
2033

2034
  /* Sticky ad adjustment - add bottom padding instead of positioning */
2035
  &.stickyAdRendered {
2036
    padding-bottom: calc(1rem + $sticky-banner-height-mobile);
2037

2038
    @media (min-height: $mobile-tall) {
2039
      padding-bottom: calc(1rem + $sticky-banner-height-mobile-tall);
2040
    }
2041

2042
    @media (min-height: $desktop-tall) {
2043
      padding-bottom: calc(1rem + $sticky-banner-height-desktop-tall);
2044
    }
2045
  }
2046
}
2047

2048
.footer-buttons {
2049
  display: flex;
2050
  gap: 0.75rem;
2051
  width: 100%;
2052
  max-width: 600px;
2053
  margin: 0 auto;
2054

2055
  .cancel-button,
2056
  .reply-button {
2057
    flex: 1;
2058
    width: auto !important;
2059
    display: flex !important;
2060
    justify-content: center;
2061
  }
2062

2063
  /* Mobile: only Reply button visible, full width */
2064
  @media (max-width: 767.98px) {
2065
    .cancel-button {
2066
      display: none !important;
2067
    }
2068

2069
    .reply-button {
2070
      width: 100% !important;
2071
    }
2072
  }
2073
}
2074

2075
/* When only Cancel button is shown (own posts), full width */
2076
.footer-buttons:has(.cancel-button:only-child) .cancel-button {
2077
  flex: 1;
2078
  width: 100% !important;
2079
}
2080

2081
.reply-expanded-section {
2082
  max-height: 70vh;
2083
  overflow-y: auto;
2084
}
2085

2086
.promised-notice {
2087
  text-align: center;
2088
  color: $color-orange--dark;
2089
  font-size: 0.85rem;
2090
  font-weight: 500;
2091
}
2092

2093
// Fullscreen map viewer
2094
.fullscreen-map-viewer {
2095
  position: fixed;
2096
  top: 0;
2097
  left: 0;
2098
  right: 0;
2099
  bottom: 0;
2100
  background: $color-gray--lighter;
2101
  z-index: 10000;
2102
  display: flex;
2103
  flex-direction: column;
2104
}
2105

2106
.map-back-button {
2107
  position: absolute;
2108
  top: env(safe-area-inset-top, 0);
2109
  left: 0;
2110
  margin: 1rem;
2111
  width: 44px;
2112
  height: 44px;
2113
  border-radius: 50%;
2114
  background: $color-white-opacity-95;
2115
  border: none;
2116
  color: $color-gray--darker;
2117
  display: flex;
2118
  align-items: center;
2119
  justify-content: center;
2120
  cursor: pointer;
2121
  z-index: 10001;
2122
  font-size: 1.25rem;
2123
  box-shadow: 0 2px 8px $color-black-opacity-20;
2124

2125
  &:active {
2126
    background: $color-white;
2127
  }
2128
}
2129

2130
.fullscreen-map {
2131
  flex: 1;
2132
  width: 100%;
2133
  height: 100% !important;
2134

2135
  :deep(.leaflet-container) {
2136
    height: 100% !important;
2137
  }
2138
}
2139

2140
.map-hint {
2141
  position: absolute;
2142
  bottom: env(safe-area-inset-bottom, 0);
2143
  left: 0;
2144
  right: 0;
2145
  margin-bottom: 1rem;
2146
  padding: 0.5rem 1rem;
2147
  background: $color-white-opacity-90;
2148
  color: $color-gray--dark;
2149
  font-size: 0.85rem;
2150
  text-align: center;
2151
  margin-left: 1rem;
2152
  margin-right: 1rem;
2153
  border-radius: 8px;
2154
  box-shadow: 0 2px 8px $color-black-opacity-10;
2155
}
2156
</style>
2157

2158
<!-- Unscoped styles for Ken Burns - scoped :deep() doesn't penetrate NuxtPicture -->
2159
<style lang="scss">
2160
@import 'bootstrap/scss/functions';
2161
@import 'bootstrap/scss/variables';
2162
@import 'bootstrap/scss/mixins/_breakpoints';
2163

2164
/* Ken Burns effect - slow pan and zoom for ~10s then stop centered, mobile/tablet only */
2165
@keyframes kenburns {
2166
  0% {
2167
    transform: scale(1.15) translate(3%, 3%);
2168
  }
2169
  50% {
2170
    transform: scale(1.15) translate(-3%, -3%);
2171
  }
2172
  100% {
2173
    transform: scale(1) translate(0%, 0%);
2174
  }
2175
}
2176

2177
.photo-container.ken-burns img {
2178
  animation: kenburns 10s ease-in-out forwards;
2179
  will-change: transform;
2180
  transform-origin: center center;
2181

2182
  /* Disable on desktop (lg and up) */
2183
  @include media-breakpoint-up(lg) {
2184
    animation: none;
2185
    transform: none;
2186
  }
2187
}
2188

2189
@media (prefers-reduced-motion: reduce) {
2190
  .photo-container.ken-burns img {
2191
    animation: none !important;
2192
  }
2193
}
2194
</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