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

Freegle / iznik-nuxt3 / fbe335f4-8133-43e0-b832-a5f31ccd600d

30 Jan 2026 02:37PM UTC coverage: 45.052% (-0.09%) from 45.14%
fbe335f4-8133-43e0-b832-a5f31ccd600d

push

circleci

CircleCI Auto-merge
Auto-merge master to production after successful tests - Original commit: fix: Show poster display name in message details "Posted By" header

3714 of 8391 branches covered (44.26%)

Branch coverage included in aggregate %.

1681 of 3584 relevant lines covered (46.9%)

114.88 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"
103!
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"
283!
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"
103!
37
            lazy
38
            src="/freegled.jpg"
39
            class="status-overlay-image"
40
            :alt="successfulText"
41
          />
42
          <b-img
43
            v-else-if="message.promised"
103!
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"
103!
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"
103✔
100
            class="photo-container"
101
            :class="{ 'ken-burns': showKenBurns }"
48!
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"
54✔
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
80✔
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
80✔
221
                  >
222
                    <v-icon icon="reply" />{{ replyCount }}
223
                  </span>
224
                  <span
225
                    v-if="message.deliverypossible && isOffer"
152✔
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"
54!
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">
103!
254
                {{ subjectLocation }}
255
              </div>
256
              <div class="photo-actions">
257
                <button
258
                  class="photo-action-btn"
259
                  @click.stop="showShareModal = true"
129✔
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>
129✔
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>
129✔
284
                  </button>
285
                  <client-only>
286
                    <button
44✔
287
                      v-if="loggedIn && message.groups?.length"
144✔
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>
42✔
294
                    </button>
295
                  </client-only>
296
                  <NuxtLink
297
                    :to="'/message/' + id"
298
                    class="section-id-link"
299
                    @click.stop
129✔
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>
314
                  <span class="section-header-text">POSTED BY</span>
78✔
315
                  <span class="section-header-name">{{
316
                    poster.displayname
317
                  }}</span>
318
                </span>
319
                <span
320
                  class="section-id-link"
321
                  @click.stop="showProfileModal = true"
78✔
322
                >
323
                  #{{ poster.id }}
324
                </span>
325
              </div>
74✔
326
              <div
327
                v-if="poster"
74✔
328
                class="poster-section-wrapper"
329
                @click.stop="showProfileModal = true"
78✔
330
              >
331
                <div class="poster-avatar-wrapper">
332
                  <ProfileImage
333
                    :image="poster.profile?.paththumb"
52!
334
                    :externaluid="poster.profile?.externaluid"
52!
335
                    :ouruid="poster.profile?.ouruid"
52!
336
                    :externalmods="poster.profile?.externalmods"
52!
337
                    :name="poster.displayname"
338
                    class="poster-avatar"
339
                    is-thumbnail
340
                    size="lg"
341
                  />
342
                  <div v-if="poster.supporter" class="supporter-badge">
52!
343
                    <v-icon icon="trophy" />
344
                  </div>
345
                </div>
346
                <div class="poster-details">
347
                  <span class="poster-name">{{ poster.displayname }}</span>
348
                  <div class="poster-stats">
349
                    <span v-if="poster.info?.offers" class="poster-stat">
350
                      <v-icon icon="gift" />{{ poster.info.offers
351
                      }}<span class="poster-stat-label">OFFERs</span>
184✔
352
                    </span>
353
                    <span
354
                      v-if="poster.info?.offers && poster.info?.wanteds"
355
                      class="poster-stat-separator"
356
                      >•</span
156✔
357
                    >
358
                    <span v-if="poster.info?.wanteds" class="poster-stat">
359
                      <v-icon icon="search" />{{ poster.info.wanteds
360
                      }}<span class="poster-stat-label">WANTEDs</span>
12✔
361
                    </span>
362
                  </div>
363
                  <div v-if="posterAboutMe" class="poster-aboutme">
52!
364
                    {{ posterAboutMe }}
365
                  </div>
366
                </div>
367
                <UserRatings
368
                  v-if="poster.id"
52!
369
                  :id="poster.id"
370
                  size="md"
371
                  :disabled="fromme"
372
                  class="poster-ratings"
373
                  @click.stop.prevent
78✔
374
                />
375
                <v-icon icon="chevron-right" class="poster-chevron" />
376
              </div>
377
            </client-only>
378
          </div>
379

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

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

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

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

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

541
    <!-- Photos Modal -->
542
    <MessagePhotosModal
543
      v-if="showMessagePhotosModal && attachmentCount"
206!
544
      :id="message.id"
545
      :initial-index="currentPhotoIndex"
546
      @hidden="showMessagePhotosModal = false"
×
547
    />
548

549
    <!-- Share Modal -->
550
    <MessageShareModal
551
      v-if="showShareModal && message.url"
206!
552
      :id="message.id"
553
      @hidden="showShareModal = false"
×
554
    />
555

556
    <!-- Profile Modal -->
557
    <ProfileModal
558
      v-if="showProfileModal && poster?.id"
206!
559
      :id="poster.id"
560
      @hidden="showProfileModal = false"
×
561
    />
562

563
    <!-- Report Modal -->
564
    <MessageReportModal
565
      v-if="showReportModal"
103!
566
      :id="id"
567
      @hidden="showReportModal = false"
26!
568
    />
569
  </div>
570
</template>
571

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

593
const MessageMap = defineAsyncComponent(() => import('~/components/MessageMap'))
26✔
594
const MessagePhotosModal = defineAsyncComponent(() =>
595
  import('~/components/MessagePhotosModal')
596
)
597
const MessageShareModal = defineAsyncComponent(() =>
598
  import('~/components/MessageShareModal')
599
)
600
const ProfileModal = defineAsyncComponent(() =>
601
  import('~/components/ProfileModal')
602
)
603
const MessageReportModal = defineAsyncComponent(() =>
604
  import('~/components/MessageReportModal')
605
)
606

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

634
const emit = defineEmits(['zoom', 'close'])
635

636
const miscStore = useMiscStore()
637
const mobileStore = useMobileStore()
638
const { me, loggedIn } = useMe()
639

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

662
const stickyAdRendered = computed(() => miscStore.stickyAdRendered)
663

664
// State
665
const replied = ref(false)
666
const replyExpanded = ref(false)
667
const mountTime = ref(null)
668
const showMapModal = ref(false)
669
const showShareModal = ref(false)
670
const showProfileModal = ref(false)
671
const showMessagePhotosModal = ref(false)
672
const showReportModal = ref(false)
673
const currentPhotoIndex = ref(0)
674
const containerRef = ref(null)
675
const thumbnailsRef = ref(null)
676
const thumbnailTouchStartX = ref(0)
677
const thumbnailScrollStart = ref(0)
678
let thumbnailScrollInterval = null
679

680
// Computed (additional to composable)
681
const currentAttachment = computed(() => {
682
  return message.value?.attachments?.[currentPhotoIndex.value]
8!
683
})
684

685
const validPosition = computed(() => {
686
  return message.value?.lat || message.value?.lng
×
687
})
688

689
const home = computed(() => {
690
  if (me.value?.lat || me.value?.lng) {
×
691
    return { lat: me.value.lat, lng: me.value.lng }
692
  }
693
  return null
694
})
695

696
const prefersReducedMotion = computed(() => {
697
  if (typeof window === 'undefined') return false
4!
698
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches
699
})
700

701
// Defer ken-burns animation to after mount to avoid SSR hydration mismatch
702
const isMounted = ref(false)
703
const showKenBurns = computed(() => {
704
  return isMounted.value && !prefersReducedMotion.value
12✔
705
})
706

707
// Check if navbar-mobile teleport target exists (only after mount to avoid hydration mismatch)
708
const navbarMobileExists = computed(() => {
709
  if (!isMounted.value) return false
52✔
710
  return !!document.getElementById('navbar-mobile')
711
})
712

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

726
const posterAboutMe = computed(() => {
727
  const text = poster.value?.aboutme?.text
96!
728
  if (!text) return null
96✔
729
  return text
730
})
731

732
// Methods
733
function goBack() {
×
734
  emit('close')
×
735
}
736

737
function showPhotosModal() {
×
738
  if (gotAttachments.value) {
×
739
    showMessagePhotosModal.value = true
740
    emit('zoom')
741
  }
742
}
743

744
function showShare() {
×
745
  showShareModal.value = true
×
746
}
747

748
function showReport() {
×
749
  showReportModal.value = true
×
750
}
751

752
function selectPhoto(index) {
×
753
  currentPhotoIndex.value = index
×
754
}
755

756
function handleThumbnailClick(index) {
×
757
  if (index === currentPhotoIndex.value) {
×
758
    // Clicking the already selected thumbnail opens the photo viewer
759
    showPhotosModal()
760
  } else {
761
    selectPhoto(index)
762
  }
763
}
764

765
function onThumbnailTouchStart(e) {
×
766
  thumbnailTouchStartX.value = e.touches[0].clientX
×
767
  thumbnailScrollStart.value = thumbnailsRef.value?.scrollLeft || 0
×
768
}
769

770
function onThumbnailTouchMove(e) {
×
771
  if (!thumbnailsRef.value) return
×
772
  const deltaX = thumbnailTouchStartX.value - e.touches[0].clientX
×
773
  thumbnailsRef.value.scrollLeft = thumbnailScrollStart.value + deltaX
×
774
}
775

776
function onThumbnailTouchEnd() {
×
777
  // Swipe complete, scroll position is already set
778
}
779

780
function startThumbnailAutoScroll() {
26✔
781
  if (!thumbnailsRef.value || attachmentCount.value <= 1) return
26!
782

783
  // Wait a moment then scroll right slowly, then back
784
  setTimeout(() => {
785
    if (!thumbnailsRef.value) return
×
786
    const maxScroll =
×
787
      thumbnailsRef.value.scrollWidth - thumbnailsRef.value.clientWidth
788
    if (maxScroll <= 0) return
×
789

790
    // Animate scroll manually for smoother control
791
    const duration = 2000 // 2 seconds to scroll
×
792
    const startTime = performance.now()
793
    const startPos = 0
794

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

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

834
function stopThumbnailAutoScroll() {
835
  if (thumbnailScrollInterval) {
836
    clearInterval(thumbnailScrollInterval)
837
    thumbnailScrollInterval = null
838
  }
839
}
840

841
function expandReply() {
21✔
842
  console.log(
21✔
843
    'DEBUG expandReply called, replyable:',
844
    props.replyable,
845
    'replied:',
846
    replied.value,
847
    'fromme:',
848
    fromme.value
849
  )
850
  replyExpanded.value = true
851
}
852

853
function sent() {
7✔
854
  replyExpanded.value = false
7✔
855
  replied.value = true
856
  // Close after a brief delay so user sees confirmation
857
  setTimeout(() => {
858
    emit('close')
5✔
859
  }, 1500)
860
}
861

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

865
function updateWindowHeight() {
26✔
866
  windowHeight.value = window.innerHeight
26✔
867
}
868

869
onMounted(() => {
870
  mountTime.value = Date.now()
26✔
871

872
  // Log mount for debugging mobile navigation issues.
873
  action('message_expanded_mount', {
874
    message_id: props.id,
875
    fullscreen_overlay: props.fullscreenOverlay,
876
    in_modal: props.inModal,
877
    breakpoint: miscStore.breakpoint,
878
  })
879

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

887
  // Enable ken-burns animation now that hydration is complete
888
  isMounted.value = true
889

890
  // Track window height for two-column layout detection (width via miscStore.breakpoint)
891
  updateWindowHeight()
892
  window.addEventListener('resize', updateWindowHeight)
893

894
  // Start auto-scroll hint for thumbnail carousel
895
  startThumbnailAutoScroll()
896
})
897

898
onUnmounted(() => {
899
  const timeOpenMs = mountTime.value ? Date.now() - mountTime.value : null
14!
900

901
  action('message_expanded_unmount', {
14✔
902
    message_id: props.id,
903
    fullscreen_overlay: props.fullscreenOverlay,
904
    time_open_ms: timeOpenMs,
905
  })
906

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

938
  stopThumbnailAutoScroll()
939
  window.removeEventListener('resize', updateWindowHeight)
940
})
941
</script>
942

943
<style scoped lang="scss">
944
@import 'bootstrap/scss/functions';
945
@import 'bootstrap/scss/variables';
946
@import 'bootstrap/scss/mixins/_breakpoints';
947
@import 'assets/css/sticky-banner.scss';
948
@import 'assets/css/_color-vars.scss';
949
@import 'assets/css/navbar.scss';
950

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

1144
/* Main wrapper - handles both modal and page contexts */
1145
.message-expanded-wrapper {
1146
  display: grid;
1147
  grid-template-rows: 1fr auto;
1148
  background: $color-white;
1149
  position: relative;
1150

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

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

1173
/* Main content area - single column by default */
1174
.message-expanded-mobile {
1175
  display: contents;
1176
}
1177

1178
/* Two-column layout wrapper */
1179
.two-column-wrapper {
1180
  display: grid;
1181
  grid-template-columns: 1fr;
1182
  grid-template-rows: auto 1fr;
1183
  min-height: 0;
1184

1185
  /* Two-column layout only in modal/overlay contexts */
1186
  .in-modal &,
1187
  .fullscreen-overlay & {
1188
    /* Use flexbox for priority-based sizing: photo grows, info sizes to content */
1189
    display: flex;
1190
    flex-direction: column;
1191
    overflow: hidden;
1192

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

1204
/* Right column for two-column layout - only active in modal/overlay */
1205
.right-column {
1206
  display: contents;
1207

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

1222
/* Inline reply section - hidden by default, shown in two-column layout (modal/overlay only) */
1223
.inline-reply-section {
1224
  display: none;
1225

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

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

1248
  /* In modal/fullscreen: grow to fill available space, shrink if needed */
1249
  .in-modal &,
1250
  .fullscreen-overlay & {
1251
    flex: 1 1 0;
1252
    max-height: none;
1253
    min-height: 100px;
1254
  }
1255

1256
  /* Two-column layout: photo fills full height of left column */
1257
  @media (min-width: 1200px) and (max-height: 700px) {
1258
    max-height: none;
1259
    height: 100%;
1260
  }
1261
}
1262

1263
// Photo container - positioned to fill photo-area
1264
.photo-container {
1265
  width: 100%;
1266
  height: 100%;
1267
  position: absolute;
1268
  top: 0;
1269
  left: 0;
1270
  overflow: hidden;
1271
}
1272

1273
// All image elements fill container
1274
.photo-container :deep(picture),
1275
.photo-container :deep(img) {
1276
  width: 100%;
1277
  height: 100%;
1278
  object-fit: cover;
1279
  display: block;
1280
}
1281

1282
// Ken Burns animation is in unscoped style block at end of file
1283

1284
// Stats pills inside title overlay
1285
.stats-pills {
1286
  display: flex;
1287
  justify-content: space-between;
1288
  align-items: center;
1289
  width: 100%;
1290
  margin-top: 0.5rem;
1291
}
1292

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

1299
.pills-right {
1300
  display: flex;
1301
  flex-wrap: wrap;
1302
  gap: 0.25rem;
1303
}
1304

1305
.stat-pill {
1306
  display: inline-flex;
1307
  align-items: center;
1308
  gap: 0.15rem;
1309
  background: $color-white-opacity-25;
1310
  color: $color-white;
1311
  padding: 0.15rem 0.4rem;
1312
  border-radius: 1rem;
1313
  font-size: 0.7rem;
1314

1315
  &.clickable {
1316
    cursor: pointer;
1317
    background: $color-blue--bright;
1318
  }
1319
}
1320

1321
.delivery-maybe {
1322
  font-weight: bold;
1323
  font-size: 0.8rem;
1324
  margin-left: -0.1rem;
1325
}
1326

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

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

1357
  &::-webkit-scrollbar {
1358
    display: none;
1359
  }
1360
}
1361

1362
.thumbnail-item {
1363
  flex-shrink: 0;
1364
  width: 50px;
1365
  height: 50px;
1366
  border-radius: 8px;
1367
  overflow: hidden;
1368
  border: 2px solid $color-white-opacity-50;
1369
  cursor: pointer;
1370
  transition: border-color 0.2s, transform 0.2s;
1371

1372
  &.active {
1373
    border-color: $color-white;
1374
    transform: scale(1.1);
1375
  }
1376

1377
  &:not(.active):hover {
1378
    border-color: $color-white-opacity-80;
1379
  }
1380
}
1381

1382
.thumbnail-image {
1383
  width: 100%;
1384
  height: 100%;
1385
  object-fit: cover;
1386
}
1387

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

1406
  &:hover {
1407
    background: $color-black-opacity-70;
1408
  }
1409

1410
  // Hide when inside a modal (use X close button instead)
1411
  .in-modal & {
1412
    display: none;
1413
  }
1414
}
1415

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

1435
  &:hover {
1436
    background: $color-gray--dark;
1437
  }
1438

1439
  // Show when inside a modal
1440
  .in-modal & {
1441
    display: flex;
1442
  }
1443
}
1444

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

1474
.info-row {
1475
  display: flex;
1476
  justify-content: space-between;
1477
  align-items: center;
1478
  gap: 0.5rem;
1479
  margin-bottom: 0.25rem;
1480
}
1481

1482
.info-icons {
1483
  display: flex;
1484
  align-items: center;
1485
  gap: 0.35rem;
1486
  font-size: 0.7rem;
1487

1488
  @include media-breakpoint-up(md) {
1489
    gap: 0.5rem;
1490
    font-size: 0.85rem;
1491
  }
1492
}
1493

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

1507
.location {
1508
  cursor: pointer;
1509
}
1510

1511
.title-row {
1512
  width: 100%;
1513
  min-width: 0;
1514
}
1515

1516
.location-row {
1517
  display: flex;
1518
  align-items: center;
1519
  justify-content: space-between;
1520
  gap: 8px;
1521
  width: 100%;
1522
}
1523

1524
.photo-actions {
1525
  display: flex;
1526
  gap: 6px;
1527
  flex-shrink: 0;
1528
  margin-left: 8px;
1529
}
1530

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

1544
  &:hover {
1545
    background: $color-white-opacity-50;
1546
  }
1547

1548
  svg {
1549
    font-size: 0.75rem;
1550
  }
1551
}
1552

1553
.title-tag {
1554
  font-size: 0.9rem !important;
1555
  white-space: nowrap !important;
1556
  flex-shrink: 0;
1557

1558
  @include media-breakpoint-up(md) {
1559
    font-size: 1rem !important;
1560
  }
1561
}
1562

1563
.title-subject {
1564
  display: block;
1565
  width: 100%;
1566
  font-size: clamp(1rem, 4vw, 1.25rem);
1567
  font-weight: 700;
1568
  line-height: 1.2;
1569
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
1570

1571
  @include media-breakpoint-up(md) {
1572
    font-size: clamp(1.25rem, 3.5vw, 1.75rem);
1573
  }
1574
}
1575

1576
.title-location {
1577
  font-size: 0.85rem;
1578
  opacity: 0.85;
1579
  margin-top: 0.15rem;
1580

1581
  @include media-breakpoint-up(md) {
1582
    font-size: 1rem;
1583
  }
1584
}
1585

1586
.photo-counter {
1587
  position: absolute;
1588
  top: 1rem;
1589
  right: 1rem;
1590
  background: $color-black-opacity-60;
1591
  color: $color-white;
1592
  padding: 0.25rem 0.6rem;
1593
  border-radius: 1rem;
1594
  font-size: 0.8rem;
1595
  z-index: 11;
1596
}
1597

1598
/* Info Section - scrollable with visible scrollbar */
1599
.info-section {
1600
  min-height: 0;
1601
  padding: 1rem;
1602

1603
  /* Visible scrollbar styling */
1604
  scrollbar-width: thin;
1605
  scrollbar-color: $color-gray--light $color-gray-3;
1606

1607
  &::-webkit-scrollbar {
1608
    width: 8px;
1609
  }
1610

1611
  &::-webkit-scrollbar-track {
1612
    background: $color-gray-3;
1613
  }
1614

1615
  &::-webkit-scrollbar-thumb {
1616
    background: $color-gray--light;
1617
    border-radius: 4px;
1618

1619
    &:hover {
1620
      background: $color-gray--base;
1621
    }
1622
  }
1623

1624
  /* In modal/fullscreen: constrain height and scroll if needed */
1625
  .in-modal &,
1626
  .fullscreen-overlay & {
1627
    flex: 0 0 auto;
1628
    max-height: 50%;
1629
    min-height: 0;
1630
    overflow-y: auto;
1631

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

1641
  /* Two-column layout on standalone pages: scroll if needed */
1642
  @media (min-width: 1200px) and (max-height: 700px) {
1643
    min-height: 0;
1644
    overflow-y: auto;
1645
  }
1646

1647
  /* In modal: make room for close button */
1648
  .in-modal & {
1649
    padding-top: 2.5rem;
1650
  }
1651
}
1652

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

1673
  &:hover {
1674
    background: $color-white;
1675
    color: $color-gray--darker;
1676
    text-decoration: none;
1677
  }
1678

1679
  /* Show on short screens (1-column only) */
1680
  @media (max-height: 700px) {
1681
    display: flex;
1682
  }
1683

1684
  /* Hide in 2-column mode - poster goes in section instead */
1685
  @media (min-width: 1200px) and (max-height: 700px) {
1686
    display: none;
1687
  }
1688
}
1689

1690
.poster-overlay-avatar-wrapper {
1691
  position: relative;
1692
  flex-shrink: 0;
1693
}
1694

1695
.poster-overlay-avatar {
1696
  :deep(.ProfileImage__container) {
1697
    width: 28px !important;
1698
    height: 28px !important;
1699
  }
1700
}
1701

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

1718
.poster-overlay-info {
1719
  display: flex;
1720
  flex-direction: column;
1721
  min-width: 0;
1722
}
1723

1724
.poster-overlay-name {
1725
  font-size: 0.75rem;
1726
  font-weight: 600;
1727
  white-space: nowrap;
1728
  overflow: hidden;
1729
  text-overflow: ellipsis;
1730
}
1731

1732
.poster-overlay-stats {
1733
  display: flex;
1734
  gap: 0.5rem;
1735
  font-size: 0.65rem;
1736
  color: $color-gray--dark;
1737
}
1738

1739
.poster-overlay-stat {
1740
  display: flex;
1741
  align-items: center;
1742
  gap: 0.15rem;
1743
}
1744

1745
.poster-overlay-separator {
1746
  color: $color-gray--base;
1747
}
1748

1749
.poster-overlay-chevron {
1750
  flex-shrink: 0;
1751
  color: $color-gray--dark;
1752
  font-size: 0.9rem;
1753
  margin-left: auto;
1754
}
1755

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

1766
  /* In modal: add right padding to avoid close button overlap */
1767
  .in-modal & {
1768
    padding-right: 3rem;
1769
  }
1770

1771
  /* POSTED BY header hides on short screens where overlay is shown (1-column only) */
1772
  &--poster {
1773
    @media (max-height: 700px) {
1774
      display: none;
1775
    }
1776

1777
    /* Show in 2-column mode - poster section is always visible there */
1778
    @media (min-width: 1200px) and (max-height: 700px) {
1779
      display: flex;
1780
    }
1781
  }
1782
}
1783

1784
.section-header-text {
1785
  font-size: 0.7rem;
1786
  font-weight: 600;
1787
  color: $color-gray--base;
1788
  letter-spacing: 0.1em;
1789
}
1790

1791
.section-header-actions {
1792
  display: flex;
1793
  align-items: center;
1794
  gap: 0.5rem;
1795
}
1796

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

1810
  &:hover {
1811
    background: $color-gray-3;
1812
    border-color: $color-gray--base;
1813
    color: $color-gray--darker;
1814
  }
1815

1816
  &--report {
1817
    color: $color-red--dark;
1818
    border-color: $color-red--light;
1819

1820
    &:hover {
1821
      background: $color-red--lighter;
1822
      border-color: $color-red--dark;
1823
    }
1824
  }
1825
}
1826

1827
.action-button-text {
1828
  display: none;
1829

1830
  @include media-breakpoint-up(md) {
1831
    display: inline;
1832
  }
1833
}
1834

1835
.section-header-name {
1836
  font-size: 0.7rem;
1837
  font-weight: 600;
1838
  color: $color-gray--darker;
1839
  margin-left: 0.35rem;
1840
}
1841

1842
.section-id-link {
1843
  font-size: 0.7rem;
1844
  font-weight: 500;
1845
  color: $color-gray--base;
1846
  text-decoration: none;
1847

1848
  &:hover {
1849
    color: $color-gray--dark;
1850
    text-decoration: underline;
1851
  }
1852
}
1853

1854
/* Poster section wrapper - clickable to open profile modal */
1855
.poster-section-wrapper {
1856
  display: flex;
1857
  align-items: flex-start;
1858
  flex-wrap: wrap;
1859
  gap: 0.5rem;
1860
  padding: 0.75rem 1rem;
1861
  margin-top: 0.5rem;
1862
  text-decoration: none;
1863
  color: inherit;
1864
  background: $color-white;
1865
  border: 1px solid $color-gray--light;
1866
  border-left: 3px solid $colour-info-fg;
1867
  cursor: pointer;
1868

1869
  &:hover {
1870
    text-decoration: none;
1871
    color: inherit;
1872
    background: $color-gray-3;
1873
  }
1874

1875
  /* Hide on short screens where overlay is shown (1-column only) */
1876
  @media (max-height: 700px) {
1877
    display: none;
1878
  }
1879

1880
  /* Show in 2-column mode - poster section is always visible there */
1881
  @media (min-width: 1200px) and (max-height: 700px) {
1882
    display: flex;
1883
  }
1884

1885
  /* Very narrow screens: stack vertically */
1886
  @media (max-width: 320px) {
1887
    flex-direction: column;
1888
    align-items: stretch;
1889
  }
1890
}
1891

1892
/* Poster aboutme - hidden on mobile, shown on tablet */
1893
.poster-aboutme {
1894
  display: none;
1895
  font-size: 0.85rem;
1896
  line-height: 1.5;
1897
  color: $color-gray--darker;
1898
  margin-top: 0.5rem;
1899
  font-style: italic;
1900
  -webkit-line-clamp: 6;
1901
  -webkit-box-orient: vertical;
1902
  overflow: hidden;
1903

1904
  &::before {
1905
    content: '"';
1906
  }
1907

1908
  &::after {
1909
    content: '"';
1910
  }
1911

1912
  @include media-breakpoint-up(md) {
1913
    display: -webkit-box;
1914
  }
1915
}
1916

1917
/* Poster ratings - hidden on mobile, shown on tablet */
1918
.poster-ratings {
1919
  display: none !important;
1920
  flex-shrink: 0;
1921

1922
  @include media-breakpoint-up(md) {
1923
    display: flex !important;
1924
  }
1925
}
1926

1927
.poster-avatar-wrapper {
1928
  position: relative;
1929
  flex-shrink: 0;
1930
}
1931

1932
.poster-avatar {
1933
  :deep(.ProfileImage__container) {
1934
    width: 48px !important;
1935
    height: 48px !important;
1936
  }
1937
}
1938

1939
.supporter-badge {
1940
  position: absolute;
1941
  bottom: 0;
1942
  right: 0;
1943
  background: gold;
1944
  color: $color-white;
1945
  width: 20px;
1946
  height: 20px;
1947
  border-radius: 50%;
1948
  display: flex;
1949
  align-items: center;
1950
  justify-content: center;
1951
  border: 2px solid $color-white;
1952
  font-size: 0.6rem;
1953
}
1954

1955
.poster-details {
1956
  flex: 1;
1957
  min-width: 0;
1958
  display: flex;
1959
  flex-direction: column;
1960
  gap: 0.15rem;
1961
  overflow: hidden;
1962
}
1963

1964
.poster-name {
1965
  font-size: 1rem;
1966
  font-weight: 600;
1967
  color: $color-gray--darker;
1968
  white-space: nowrap;
1969
  overflow: hidden;
1970
  text-overflow: ellipsis;
1971
}
1972

1973
.poster-stats {
1974
  display: flex;
1975
  align-items: center;
1976
  flex-wrap: wrap;
1977
  gap: 0.5rem;
1978
  font-size: 0.8rem;
1979
  color: $color-gray--dark;
1980
}
1981

1982
.poster-distance,
1983
.poster-stat {
1984
  display: flex;
1985
  align-items: center;
1986
  gap: 0.2rem;
1987
}
1988

1989
.poster-stat-label {
1990
  display: none;
1991
  margin-left: 0.15rem;
1992

1993
  @include media-breakpoint-up(md) {
1994
    display: inline;
1995
  }
1996
}
1997

1998
.poster-stat-separator {
1999
  color: $color-gray--base;
2000
}
2001

2002
.poster-chevron {
2003
  flex-shrink: 0;
2004
  align-self: center;
2005
  color: $color-gray--dark;
2006
  font-size: 1.25rem;
2007
  padding: 0.5rem;
2008
  margin-right: -0.5rem;
2009
}
2010

2011
// Description
2012
.description-section {
2013
  margin-bottom: 1rem;
2014
}
2015

2016
.description-content {
2017
  background: $color-white;
2018
  border: 1px solid $color-gray--light;
2019
  border-left: 3px solid $color-green--darker;
2020
  padding: 1rem;
2021
  font-size: 1rem;
2022
  line-height: 1.7;
2023
  color: $color-gray--darker;
2024

2025
  /* Ensure at least 2 lines visible */
2026
  min-height: 3.4em;
2027
}
2028

2029
.app-footer {
2030
  padding: 1rem;
2031
  border-top: 1px solid $color-gray-3;
2032
  background: $color-white;
2033
  flex-shrink: 0;
2034
  display: flex;
2035
  flex-direction: column;
2036
  justify-content: flex-end;
2037

2038
  /* Hide footer in two-column layout (modal/overlay only - reply is inline there) */
2039
  .in-modal &,
2040
  .fullscreen-overlay & {
2041
    @media (min-width: 1200px) and (max-height: 700px) {
2042
      display: none;
2043
    }
2044
  }
2045

2046
  /* Sticky ad adjustment - add bottom padding instead of positioning */
2047
  &.stickyAdRendered {
2048
    padding-bottom: calc(1rem + $sticky-banner-height-mobile);
2049

2050
    @media (min-height: $mobile-tall) {
2051
      padding-bottom: calc(1rem + $sticky-banner-height-mobile-tall);
2052
    }
2053

2054
    @media (min-height: $desktop-tall) {
2055
      padding-bottom: calc(1rem + $sticky-banner-height-desktop-tall);
2056
    }
2057
  }
2058
}
2059

2060
.footer-buttons {
2061
  display: flex;
2062
  gap: 0.75rem;
2063
  width: 100%;
2064
  max-width: 600px;
2065
  margin: 0 auto;
2066

2067
  .cancel-button,
2068
  .reply-button {
2069
    flex: 1;
2070
    width: auto !important;
2071
    display: flex !important;
2072
    justify-content: center;
2073
  }
2074

2075
  /* Mobile: only Reply button visible, full width */
2076
  @media (max-width: 767.98px) {
2077
    .cancel-button {
2078
      display: none !important;
2079
    }
2080

2081
    .reply-button {
2082
      width: 100% !important;
2083
    }
2084
  }
2085
}
2086

2087
/* When only Cancel button is shown (own posts), full width */
2088
.footer-buttons:has(.cancel-button:only-child) .cancel-button {
2089
  flex: 1;
2090
  width: 100% !important;
2091
}
2092

2093
.reply-expanded-section {
2094
  max-height: 70vh;
2095
  overflow-y: auto;
2096
}
2097

2098
.promised-notice {
2099
  text-align: center;
2100
  color: $color-orange--dark;
2101
  font-size: 0.85rem;
2102
  font-weight: 500;
2103
}
2104

2105
// Fullscreen map viewer
2106
.fullscreen-map-viewer {
2107
  position: fixed;
2108
  top: 0;
2109
  left: 0;
2110
  right: 0;
2111
  bottom: 0;
2112
  background: $color-gray--lighter;
2113
  z-index: 10000;
2114
  display: flex;
2115
  flex-direction: column;
2116
}
2117

2118
.map-back-button {
2119
  position: absolute;
2120
  top: env(safe-area-inset-top, 0);
2121
  left: 0;
2122
  margin: 1rem;
2123
  width: 44px;
2124
  height: 44px;
2125
  border-radius: 50%;
2126
  background: $color-white-opacity-95;
2127
  border: none;
2128
  color: $color-gray--darker;
2129
  display: flex;
2130
  align-items: center;
2131
  justify-content: center;
2132
  cursor: pointer;
2133
  z-index: 10001;
2134
  font-size: 1.25rem;
2135
  box-shadow: 0 2px 8px $color-black-opacity-20;
2136

2137
  &:active {
2138
    background: $color-white;
2139
  }
2140
}
2141

2142
.fullscreen-map {
2143
  flex: 1;
2144
  width: 100%;
2145
  height: 100% !important;
2146

2147
  :deep(.leaflet-container) {
2148
    height: 100% !important;
2149
  }
2150
}
2151

2152
.map-hint {
2153
  position: absolute;
2154
  bottom: env(safe-area-inset-bottom, 0);
2155
  left: 0;
2156
  right: 0;
2157
  margin-bottom: 1rem;
2158
  padding: 0.5rem 1rem;
2159
  background: $color-white-opacity-90;
2160
  color: $color-gray--dark;
2161
  font-size: 0.85rem;
2162
  text-align: center;
2163
  margin-left: 1rem;
2164
  margin-right: 1rem;
2165
  border-radius: 8px;
2166
  box-shadow: 0 2px 8px $color-black-opacity-10;
2167
}
2168
</style>
2169

2170
<!-- Unscoped styles for Ken Burns - scoped :deep() doesn't penetrate NuxtPicture -->
2171
<style lang="scss">
2172
@import 'bootstrap/scss/functions';
2173
@import 'bootstrap/scss/variables';
2174
@import 'bootstrap/scss/mixins/_breakpoints';
2175

2176
/* Ken Burns effect - slow pan and zoom for ~10s then stop centered, mobile/tablet only */
2177
@keyframes kenburns {
2178
  0% {
2179
    transform: scale(1.15) translate(3%, 3%);
2180
  }
2181
  50% {
2182
    transform: scale(1.15) translate(-3%, -3%);
2183
  }
2184
  100% {
2185
    transform: scale(1) translate(0%, 0%);
2186
  }
2187
}
2188

2189
.photo-container.ken-burns img {
2190
  animation: kenburns 10s ease-in-out forwards;
2191
  will-change: transform;
2192
  transform-origin: center center;
2193

2194
  /* Disable on desktop (lg and up) */
2195
  @include media-breakpoint-up(lg) {
2196
    animation: none;
2197
    transform: none;
2198
  }
2199
}
2200

2201
@media (prefers-reduced-motion: reduce) {
2202
  .photo-container.ken-burns img {
2203
    animation: none !important;
2204
  }
2205
}
2206
</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